diff --git a/.eslintrc.json b/.eslintrc.json index 8d20e22..95cba7d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,6 @@ { "extends": "./node_modules/mwts/", - "ignorePatterns": ["node_modules", "dist", "test", "jest.config.js", "typings"], + "ignorePatterns": ["node_modules", "dist", "test", "jest.config.js", "typings", "scripts"], "env": { "jest": true } diff --git a/.gitignore b/.gitignore index cd12d03..e2c1f24 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,8 @@ run/ yarn.lock **/config.prod.ts **/config.local.ts -container \ No newline at end of file +container +scripts +ai +tmp_uploads/ +.trae \ No newline at end of file diff --git a/area-api-doc.md b/area-api-doc.md new file mode 100644 index 0000000..6dd8f8e --- /dev/null +++ b/area-api-doc.md @@ -0,0 +1,254 @@ +# Area 区域管理 API 文档 + +## 概述 + +本文档详细描述了区域管理相关的API接口,包括增删改查操作以及新增的坐标功能. + +## API 接口列表 + +### 1. 创建区域 + +**请求信息** +- URL: `/api/area` +- Method: `POST` +- Headers: `Authorization: Bearer {token}` + +**请求体 (JSON)** +```json +{ + "name": "欧洲", + "latitude": 48.8566, + "longitude": 2.3522 +} +``` + +**参数说明** +- `name`: 区域名称 (必填) +- `latitude`: 纬度 (-90 到 90 之间,可选) +- `longitude`: 经度 (-180 到 180 之间,可选) + +**响应示例** +```json +{ + "code": 0, + "message": "创建成功", + "data": { + "id": 1, + "name": "欧洲", + "latitude": 48.8566, + "longitude": 2.3522, + "createdAt": "2024-01-01T12:00:00Z", + "updatedAt": "2024-01-01T12:00:00Z" + } +} +``` + +### 2. 更新区域 + +**请求信息** +- URL: `/api/area/{id}` +- Method: `PUT` +- Headers: `Authorization: Bearer {token}` + +**请求体 (JSON)** +```json +{ + "name": "欧洲区域", + "latitude": 48.8566, + "longitude": 2.3522 +} +``` + +**参数说明** +- `name`: 区域名称 (可选) +- `latitude`: 纬度 (-90 到 90 之间,可选) +- `longitude`: 经度 (-180 到 180 之间,可选) + +**响应示例** +```json +{ + "code": 0, + "message": "更新成功", + "data": { + "id": 1, + "name": "欧洲区域", + "latitude": 48.8566, + "longitude": 2.3522, + "createdAt": "2024-01-01T12:00:00Z", + "updatedAt": "2024-01-01T12:30:00Z" + } +} +``` + +### 3. 删除区域 + +**请求信息** +- URL: `/api/area/{id}` +- Method: `DELETE` +- Headers: `Authorization: Bearer {token}` + +**响应示例** +```json +{ + "code": 0, + "message": "删除成功", + "data": null +} +``` + +### 4. 获取区域列表(分页) + +**请求信息** +- URL: `/api/area` +- Method: `GET` +- Headers: `Authorization: Bearer {token}` +- Query Parameters: + - `currentPage`: 当前页码 (默认 1) + - `pageSize`: 每页数量 (默认 10) + - `name`: 区域名称(可选,用于搜索) + +**响应示例** +```json +{ + "code": 0, + "message": "查询成功", + "data": { + "list": [ + { + "id": 1, + "name": "欧洲", + "latitude": 48.8566, + "longitude": 2.3522, + "createdAt": "2024-01-01T12:00:00Z", + "updatedAt": "2024-01-01T12:00:00Z" + } + ], + "total": 1 + } +} +``` + +### 5. 获取所有区域 + +**请求信息** +- URL: `/api/area/all` +- Method: `GET` +- Headers: `Authorization: Bearer {token}` + +**响应示例** +```json +{ + "code": 0, + "message": "查询成功", + "data": [ + { + "id": 1, + "name": "欧洲", + "latitude": 48.8566, + "longitude": 2.3522, + "createdAt": "2024-01-01T12:00:00Z", + "updatedAt": "2024-01-01T12:00:00Z" + }, + { + "id": 2, + "name": "亚洲", + "latitude": 35.6762, + "longitude": 139.6503, + "createdAt": "2024-01-01T12:10:00Z", + "updatedAt": "2024-01-01T12:10:00Z" + } + ] +} +``` + +### 6. 根据ID获取区域详情 + +**请求信息** +- URL: `/api/area/{id}` +- Method: `GET` +- Headers: `Authorization: Bearer {token}` + +**响应示例** +```json +{ + "code": 0, + "message": "查询成功", + "data": { + "id": 1, + "name": "欧洲", + "latitude": 48.8566, + "longitude": 2.3522, + "createdAt": "2024-01-01T12:00:00Z", + "updatedAt": "2024-01-01T12:00:00Z" + } +} +``` + +## 世界地图实现建议 + +对于前端实现世界地图并显示区域坐标,推荐以下方案: + +### 1. 使用开源地图库 + +- **Leaflet.js**: 轻量级开源地图库,易于集成 +- **Mapbox**: 提供丰富的地图样式和交互功能 +- **Google Maps API**: 功能强大但需要API密钥 + +### 2. 实现步骤 + +1. **获取区域数据**: + 使用 `/api/area/all` 接口获取所有包含坐标信息的区域 + +2. **初始化地图**: + ```javascript + // Leaflet示例 + const map = L.map('map').setView([0, 0], 2); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(map); + ``` + +3. **添加区域标记**: + ```javascript + // 假设areas是从API获取的数据 + areas.forEach(area => { + if (area.latitude && area.longitude) { + const marker = L.marker([area.latitude, area.longitude]).addTo(map); + marker.bindPopup(`${area.name}`); + } + }); + ``` + +4. **添加交互功能**: + - 点击标记显示区域详情 + - 搜索和筛选功能 + - 编辑坐标功能(调用更新API) + +### 3. 坐标输入建议 + +在区域管理界面,可以添加以下功能来辅助坐标输入: + +1. 提供搜索框,根据地点名称自动获取坐标 +2. 集成小型地图,允许用户点击选择位置 +3. 添加验证,确保输入的坐标在有效范围内 + +## 数据模型说明 + +### Area 实体 + +| 字段名 | 类型 | 描述 | 是否必填 | +|--------|------|------|----------| +| id | number | 区域ID | 否(自动生成) | +| name | string | 区域名称 | 是 | +| latitude | number | 纬度(范围:-90 到 90) | 否 | +| longitude | number | 经度(范围:-180 到 180) | 否 | +| createdAt | Date | 创建时间 | 否(自动生成) | +| updatedAt | Date | 更新时间 | 否(自动生成) | + +## 错误处理 + +API可能返回的错误信息: + +- `区域名称已存在`: 当尝试创建或更新区域名称与现有名称重复时 +- `区域不存在`: 当尝试更新或删除不存在的区域时 +- `权限错误`: 当请求缺少有效的授权令牌时 \ No newline at end of file diff --git a/debug_sync.log b/debug_sync.log new file mode 100644 index 0000000..e39a7b1 --- /dev/null +++ b/debug_sync.log @@ -0,0 +1,54 @@ +[2025-12-12T10:44:39.963Z] [BatchSync] Starting sync to site 1 for products: [561,560] +[2025-12-12T10:44:39.978Z] [BatchSync] Found 2 products in local DB +[2025-12-12T10:44:39.992Z] [BatchSync] Payload - Create: 2, Update: 0 +[2025-12-12T10:44:39.993Z] [BatchSync] Create Payload: [{"name":"Pablo-Mango lce-30MG-wet","type":"simple","regular_price":"0.00","sale_price":"0.00","sku":"SPPablo-Mango lce-30MG-wet","status":"publish"},{"name":"YOONE- ICE WINTERGREEN-9MG-wet","type":"simple","regular_price":"0.00","sale_price":"0.00","sku":"SPYOONE- ICE WINTERGREEN-9MG-wet","status":"publish"}] +[2025-12-12T10:44:41.048Z] [BatchSync] API Success. Result: {"create":[{"id":279,"name":"Pablo-Mango lce-30MG-wet","slug":"pablo-mango-lce-30mg-wet","permalink":"http://simple.local/product/pablo-mango-lce-30mg-wet/","date_created":"2025-12-12T10:44:40","date_created_gmt":"2025-12-12T10:44:40","date_modified":"2025-12-12T10:44:40","date_modified_gmt":"2025-12-12T10:44:40","type":"simple","status":"publish","featured":false,"catalog_visibility":"visible","description":"","short_description":"","sku":"SPPablo-Mango lce-30MG-wet","price":"0.00","regular_price":"0.00","sale_price":"","date_on_sale_from":null,"date_on_sale_from_gmt":null,"date_on_sale_to":null,"date_on_sale_to_gmt":null,"on_sale":false,"purchasable":true,"total_sales":0,"virtual":false,"downloadable":false,"downloads":[],"download_limit":-1,"download_expiry":-1,"external_url":"","button_text":"","tax_status":"taxable","tax_class":"","manage_stock":false,"stock_quantity":null,"backorders":"no","backorders_allowed":false,"backordered":false,"low_stock_amount":null,"sold_individually":false,"weight":"","dimensions":{"length":"","width":"","height":""},"shipping_required":true,"shipping_taxable":true,"shipping_class":"","shipping_class_id":0,"reviews_allowed":true,"average_rating":"0","rating_count":0,"upsell_ids":[],"cross_sell_ids":[],"parent_id":0,"purchase_note":"","categories":[{"id":15,"name":"Uncategorized","slug":"uncategorized"}],"brands":[],"tags":[],"images":[],"attributes":[],"default_attributes":[],"variations":[],"grouped_products":[],"menu_order":0,"price_html":"$0.00 available on subscription","related_ids":[271,276,81,64,60],"meta_data":[{"key":"_subscription_payment_sync_date","value":0}],"stock_status":"instock","has_options":false,"post_password":"","global_unique_id":"","permalink_template":"http://simple.local/product/%pagename%/","generated_slug":"pablo-mango-lce-30mg-wet","bundled_by":[],"bundle_stock_status":"instock","bundle_stock_quantity":null,"bundle_virtual":false,"bundle_layout":"","bundle_add_to_cart_form_location":"","bundle_editable_in_cart":false,"bundle_sold_individually_context":"","bundle_item_grouping":"","bundle_min_size":"","bundle_max_size":"","bundled_items":[],"bundle_sell_ids":[],"_links":{"self":[{"href":"http://simple.local/wp-json/wc/v3/products/279","targetHints":{"allow":["GET","POST","PUT","PATCH","DELETE"]}}],"collection":[{"href":"http://simple.local/wp-json/wc/v3/products"}]}},{"id":280,"name":"YOONE- ICE WINTERGREEN-9MG-wet","slug":"yoone-ice-wintergreen-9mg-wet","permalink":"http://simple.local/product/yoone-ice-wintergreen-9mg-wet/","date_created":"2025-12-12T10:44:40","date_created_gmt":"2025-12-12T10:44:40","date_modified":"2025-12-12T10:44:40","date_modified_gmt":"2025-12-12T10:44:40","type":"simple","status":"publish","featured":false,"catalog_visibility":"visible","description":"","short_description":"","sku":"SPYOONE- ICE WINTERGREEN-9MG-wet","price":"0.00","regular_price":"0.00","sale_price":"","date_on_sale_from":null,"date_on_sale_from_gmt":null,"date_on_sale_to":null,"date_on_sale_to_gmt":null,"on_sale":false,"purchasable":true,"total_sales":0,"virtual":false,"downloadable":false,"downloads":[],"download_limit":-1,"download_expiry":-1,"external_url":"","button_text":"","tax_status":"taxable","tax_class":"","manage_stock":false,"stock_quantity":null,"backorders":"no","backorders_allowed":false,"backordered":false,"low_stock_amount":null,"sold_individually":false,"weight":"","dimensions":{"length":"","width":"","height":""},"shipping_required":true,"shipping_taxable":true,"shipping_class":"","shipping_class_id":0,"reviews_allowed":true,"average_rating":"0","rating_count":0,"upsell_ids":[],"cross_sell_ids":[],"parent_id":0,"purchase_note":"","categories":[{"id":15,"name":"Uncategorized","slug":"uncategorized"}],"brands":[],"tags":[],"images":[],"attributes":[],"default_attributes":[],"variations":[],"grouped_products":[],"menu_order":0,"price_html":"$0.00 available on subscription","related_ids":[276,279,271,60,64],"meta_data":[{"key":"_subscription_payment_sync_date","value":0}],"stock_status":"instock","has_options":false,"post_password":"","global_unique_id":"","permalink_template":"http://simple.local/product/%pagename%/","generated_slug":"yoone-ice-wintergreen-9mg-wet","bundled_by":[],"bundle_stock_status":"instock","bundle_stock_quantity":null,"bundle_virtual":false,"bundle_layout":"","bundle_add_to_cart_form_location":"","bundle_editable_in_cart":false,"bundle_sold_individually_context":"","bundle_item_grouping":"","bundle_min_size":"","bundle_max_size":"","bundled_items":[],"bundle_sell_ids":[],"_links":{"self":[{"href":"http://simple.local/wp-json/wc/v3/products/280","targetHints":{"allow":["GET","POST","PUT","PATCH","DELETE"]}}],"collection":[{"href":"http://simple.local/wp-json/wc/v3/products"}]}}]} +[2025-12-12T10:44:41.049Z] [BatchSync] Processing success item: ID=279, SKU=SPPablo-Mango lce-30MG-wet +[2025-12-12T10:44:41.049Z] [BatchSync] Found local product ID=560 for SKU=SPPablo-Mango lce-30MG-wet +[2025-12-12T10:44:41.054Z] [BatchSync] Creating ProductSiteSku for productId=560 code=SPPablo-Mango lce-30MG-wet +[2025-12-12T10:44:41.102Z] [BatchSync] Processing success item: ID=280, SKU=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T10:44:41.102Z] [BatchSync] Found local product ID=561 for SKU=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T10:44:41.104Z] [BatchSync] Creating ProductSiteSku for productId=561 code=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T10:59:13.077Z] [BatchSync] Starting sync to site 1 for products: [361] +[2025-12-12T10:59:13.091Z] [BatchSync] Found 1 products in local DB +[2025-12-12T10:59:13.094Z] [BatchSync] Payload - Create: 1, Update: 0 +[2025-12-12T10:59:13.094Z] [BatchSync] Create Payload: [{"name":"YOONE-NP-S-MA-6MG-DRY","type":"simple","regular_price":"0.00","sale_price":"0.00","sku":"SPYOONE-NP-S-MA-6MG-DRY","status":"publish"}] +[2025-12-12T10:59:13.917Z] [BatchSync] API Success. Result: {"create":[{"id":281,"name":"YOONE-NP-S-MA-6MG-DRY","slug":"yoone-np-s-ma-6mg-dry","permalink":"http://simple.local/product/yoone-np-s-ma-6mg-dry/","date_created":"2025-12-12T10:59:13","date_created_gmt":"2025-12-12T10:59:13","date_modified":"2025-12-12T10:59:13","date_modified_gmt":"2025-12-12T10:59:13","type":"simple","status":"publish","featured":false,"catalog_visibility":"visible","description":"","short_description":"","sku":"SPYOONE-NP-S-MA-6MG-DRY","price":"0.00","regular_price":"0.00","sale_price":"","date_on_sale_from":null,"date_on_sale_from_gmt":null,"date_on_sale_to":null,"date_on_sale_to_gmt":null,"on_sale":false,"purchasable":true,"total_sales":0,"virtual":false,"downloadable":false,"downloads":[],"download_limit":-1,"download_expiry":-1,"external_url":"","button_text":"","tax_status":"taxable","tax_class":"","manage_stock":false,"stock_quantity":null,"backorders":"no","backorders_allowed":false,"backordered":false,"low_stock_amount":null,"sold_individually":false,"weight":"","dimensions":{"length":"","width":"","height":""},"shipping_required":true,"shipping_taxable":true,"shipping_class":"","shipping_class_id":0,"reviews_allowed":true,"average_rating":"0","rating_count":0,"upsell_ids":[],"cross_sell_ids":[],"parent_id":0,"purchase_note":"","categories":[{"id":15,"name":"Uncategorized","slug":"uncategorized"}],"brands":[],"tags":[],"images":[],"attributes":[],"default_attributes":[],"variations":[],"grouped_products":[],"menu_order":0,"price_html":"$0.00 available on subscription","related_ids":[81,60,271,276,64],"meta_data":[{"key":"_subscription_payment_sync_date","value":0}],"stock_status":"instock","has_options":false,"post_password":"","global_unique_id":"","permalink_template":"http://simple.local/product/%pagename%/","generated_slug":"yoone-np-s-ma-6mg-dry","bundled_by":[],"bundle_stock_status":"instock","bundle_stock_quantity":null,"bundle_virtual":false,"bundle_layout":"","bundle_add_to_cart_form_location":"","bundle_editable_in_cart":false,"bundle_sold_individually_context":"","bundle_item_grouping":"","bundle_min_size":"","bundle_max_size":"","bundled_items":[],"bundle_sell_ids":[],"_links":{"self":[{"href":"http://simple.local/wp-json/wc/v3/products/281","targetHints":{"allow":["GET","POST","PUT","PATCH","DELETE"]}}],"collection":[{"href":"http://simple.local/wp-json/wc/v3/products"}]}}]} +[2025-12-12T10:59:13.918Z] [BatchSync] Processing success item: ID=281, SKU=SPYOONE-NP-S-MA-6MG-DRY +[2025-12-12T10:59:13.919Z] [BatchSync] Found local product ID=361 for SKU=SPYOONE-NP-S-MA-6MG-DRY +[2025-12-12T10:59:13.922Z] [BatchSync] Creating ProductSiteSku for productId=361 code=SPYOONE-NP-S-MA-6MG-DRY +[2025-12-12T11:03:42.518Z] [BatchSync] Starting sync to site 1 for products: [361] +[2025-12-12T11:03:42.529Z] [BatchSync] Found 1 products in local DB +[2025-12-12T11:03:42.534Z] [BatchSync] Payload - Create: 0, Update: 1 +[2025-12-12T11:03:42.534Z] [BatchSync] Update Payload: [{"id":"281","name":"YOONE-NP-S-MA-6MG-DRY","type":"simple","regular_price":"0.00","sale_price":"0.00","sku":"SPYOONE-NP-S-MA-6MG-DRY","status":"publish"}] +[2025-12-12T11:03:43.283Z] [BatchSync] API Success. Result: {"update":[{"id":281,"name":"YOONE-NP-S-MA-6MG-DRY","slug":"yoone-np-s-ma-6mg-dry","permalink":"http://simple.local/product/yoone-np-s-ma-6mg-dry/","date_created":"2025-12-12T10:59:13","date_created_gmt":"2025-12-12T10:59:13","date_modified":"2025-12-12T11:03:43","date_modified_gmt":"2025-12-12T11:03:43","type":"simple","status":"publish","featured":false,"catalog_visibility":"visible","description":"","short_description":"","sku":"SPYOONE-NP-S-MA-6MG-DRY","price":"0.00","regular_price":"0.00","sale_price":"","date_on_sale_from":null,"date_on_sale_from_gmt":null,"date_on_sale_to":null,"date_on_sale_to_gmt":null,"on_sale":false,"purchasable":true,"total_sales":0,"virtual":false,"downloadable":false,"downloads":[],"download_limit":-1,"download_expiry":-1,"external_url":"","button_text":"","tax_status":"taxable","tax_class":"","manage_stock":false,"stock_quantity":null,"backorders":"no","backorders_allowed":false,"backordered":false,"low_stock_amount":null,"sold_individually":false,"weight":"","dimensions":{"length":"","width":"","height":""},"shipping_required":true,"shipping_taxable":true,"shipping_class":"","shipping_class_id":0,"reviews_allowed":true,"average_rating":"0","rating_count":0,"upsell_ids":[],"cross_sell_ids":[],"parent_id":0,"purchase_note":"","categories":[{"id":15,"name":"Uncategorized","slug":"uncategorized"}],"brands":[],"tags":[],"images":[],"attributes":[],"default_attributes":[],"variations":[],"grouped_products":[],"menu_order":0,"price_html":"$0.00 available on subscription","related_ids":[60,276,279,64,271],"meta_data":[{"key":"_subscription_payment_sync_date","value":0}],"stock_status":"instock","has_options":false,"post_password":"","global_unique_id":"","permalink_template":"http://simple.local/product/%pagename%/","generated_slug":"yoone-np-s-ma-6mg-dry","bundled_by":[],"bundle_stock_status":"instock","bundle_stock_quantity":null,"bundle_virtual":false,"bundle_layout":"","bundle_add_to_cart_form_location":"","bundle_editable_in_cart":false,"bundle_sold_individually_context":"","bundle_item_grouping":"","bundle_min_size":"","bundle_max_size":"","bundled_items":[],"bundle_sell_ids":[],"_links":{"self":[{"href":"http://simple.local/wp-json/wc/v3/products/281","targetHints":{"allow":["GET","POST","PUT","PATCH","DELETE"]}}],"collection":[{"href":"http://simple.local/wp-json/wc/v3/products"}]}}]} +[2025-12-12T11:03:43.284Z] [BatchSync] Processing success item: ID=281, SKU=SPYOONE-NP-S-MA-6MG-DRY +[2025-12-12T11:03:43.285Z] [BatchSync] Found local product ID=361 for SKU=SPYOONE-NP-S-MA-6MG-DRY +[2025-12-12T11:03:43.290Z] [BatchSync] ProductSiteSku already exists for productId=361 code=SPYOONE-NP-S-MA-6MG-DRY +[2025-12-12T11:05:01.270Z] [BatchSync] Starting sync to site 1 for products: [561] +[2025-12-12T11:05:01.282Z] [BatchSync] Found 1 products in local DB +[2025-12-12T11:05:01.285Z] [BatchSync] Payload - Create: 0, Update: 1 +[2025-12-12T11:05:01.285Z] [BatchSync] Update Payload: [{"id":"280","name":"YOONE- ICE WINTERGREEN-9MG-wet","type":"simple","regular_price":"0.00","sale_price":"0.00","sku":"SPYOONE- ICE WINTERGREEN-9MG-wet","status":"publish"}] +[2025-12-12T11:05:02.050Z] [BatchSync] API Success. Result: {"update":[{"id":280,"name":"YOONE- ICE WINTERGREEN-9MG-wet","slug":"yoone-ice-wintergreen-9mg-wet","permalink":"http://simple.local/product/yoone-ice-wintergreen-9mg-wet/","date_created":"2025-12-12T10:44:40","date_created_gmt":"2025-12-12T10:44:40","date_modified":"2025-12-12T11:05:02","date_modified_gmt":"2025-12-12T11:05:02","type":"simple","status":"publish","featured":false,"catalog_visibility":"visible","description":"","short_description":"","sku":"SPYOONE- ICE WINTERGREEN-9MG-wet","price":"0.00","regular_price":"0.00","sale_price":"","date_on_sale_from":null,"date_on_sale_from_gmt":null,"date_on_sale_to":null,"date_on_sale_to_gmt":null,"on_sale":false,"purchasable":true,"total_sales":0,"virtual":false,"downloadable":false,"downloads":[],"download_limit":-1,"download_expiry":-1,"external_url":"","button_text":"","tax_status":"taxable","tax_class":"","manage_stock":false,"stock_quantity":null,"backorders":"no","backorders_allowed":false,"backordered":false,"low_stock_amount":null,"sold_individually":false,"weight":"","dimensions":{"length":"","width":"","height":""},"shipping_required":true,"shipping_taxable":true,"shipping_class":"","shipping_class_id":0,"reviews_allowed":true,"average_rating":"0","rating_count":0,"upsell_ids":[],"cross_sell_ids":[],"parent_id":0,"purchase_note":"","categories":[{"id":15,"name":"Uncategorized","slug":"uncategorized"}],"brands":[],"tags":[],"images":[],"attributes":[],"default_attributes":[],"variations":[],"grouped_products":[],"menu_order":0,"price_html":"$0.00 available on subscription","related_ids":[271,281,60,279,64],"meta_data":[{"key":"_subscription_payment_sync_date","value":0}],"stock_status":"instock","has_options":false,"post_password":"","global_unique_id":"","permalink_template":"http://simple.local/product/%pagename%/","generated_slug":"yoone-ice-wintergreen-9mg-wet","bundled_by":[],"bundle_stock_status":"instock","bundle_stock_quantity":null,"bundle_virtual":false,"bundle_layout":"","bundle_add_to_cart_form_location":"","bundle_editable_in_cart":false,"bundle_sold_individually_context":"","bundle_item_grouping":"","bundle_min_size":"","bundle_max_size":"","bundled_items":[],"bundle_sell_ids":[],"_links":{"self":[{"href":"http://simple.local/wp-json/wc/v3/products/280","targetHints":{"allow":["GET","POST","PUT","PATCH","DELETE"]}}],"collection":[{"href":"http://simple.local/wp-json/wc/v3/products"}]}}]} +[2025-12-12T11:05:02.051Z] [BatchSync] Processing success item: ID=280, SKU=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T11:05:02.051Z] [BatchSync] Found local product ID=561 for SKU=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T11:05:02.054Z] [BatchSync] ProductSiteSku already exists for productId=561 code=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T11:05:18.179Z] [BatchSync] Starting sync to site 1 for products: [561] +[2025-12-12T11:05:18.188Z] [BatchSync] Found 1 products in local DB +[2025-12-12T11:05:18.193Z] [BatchSync] Payload - Create: 0, Update: 1 +[2025-12-12T11:05:18.194Z] [BatchSync] Update Payload: [{"id":"280","name":"YOONE- ICE WINTERGREEN-9MG-wet","type":"simple","regular_price":"0.00","sale_price":"0.00","sku":"SPYOONE- ICE WINTERGREEN-9MG-wet","status":"publish"}] +[2025-12-12T11:05:19.031Z] [BatchSync] API Success. Result: {"update":[{"id":280,"name":"YOONE- ICE WINTERGREEN-9MG-wet","slug":"yoone-ice-wintergreen-9mg-wet","permalink":"http://simple.local/product/yoone-ice-wintergreen-9mg-wet/","date_created":"2025-12-12T10:44:40","date_created_gmt":"2025-12-12T10:44:40","date_modified":"2025-12-12T11:05:19","date_modified_gmt":"2025-12-12T11:05:19","type":"simple","status":"publish","featured":false,"catalog_visibility":"visible","description":"","short_description":"","sku":"SPYOONE- ICE WINTERGREEN-9MG-wet","price":"0.00","regular_price":"0.00","sale_price":"","date_on_sale_from":null,"date_on_sale_from_gmt":null,"date_on_sale_to":null,"date_on_sale_to_gmt":null,"on_sale":false,"purchasable":true,"total_sales":0,"virtual":false,"downloadable":false,"downloads":[],"download_limit":-1,"download_expiry":-1,"external_url":"","button_text":"","tax_status":"taxable","tax_class":"","manage_stock":false,"stock_quantity":null,"backorders":"no","backorders_allowed":false,"backordered":false,"low_stock_amount":null,"sold_individually":false,"weight":"","dimensions":{"length":"","width":"","height":""},"shipping_required":true,"shipping_taxable":true,"shipping_class":"","shipping_class_id":0,"reviews_allowed":true,"average_rating":"0","rating_count":0,"upsell_ids":[],"cross_sell_ids":[],"parent_id":0,"purchase_note":"","categories":[{"id":15,"name":"Uncategorized","slug":"uncategorized"}],"brands":[],"tags":[],"images":[],"attributes":[],"default_attributes":[],"variations":[],"grouped_products":[],"menu_order":0,"price_html":"$0.00 available on subscription","related_ids":[279,276,271,60,81],"meta_data":[{"key":"_subscription_payment_sync_date","value":0}],"stock_status":"instock","has_options":false,"post_password":"","global_unique_id":"","permalink_template":"http://simple.local/product/%pagename%/","generated_slug":"yoone-ice-wintergreen-9mg-wet","bundled_by":[],"bundle_stock_status":"instock","bundle_stock_quantity":null,"bundle_virtual":false,"bundle_layout":"","bundle_add_to_cart_form_location":"","bundle_editable_in_cart":false,"bundle_sold_individually_context":"","bundle_item_grouping":"","bundle_min_size":"","bundle_max_size":"","bundled_items":[],"bundle_sell_ids":[],"_links":{"self":[{"href":"http://simple.local/wp-json/wc/v3/products/280","targetHints":{"allow":["GET","POST","PUT","PATCH","DELETE"]}}],"collection":[{"href":"http://simple.local/wp-json/wc/v3/products"}]}}]} +[2025-12-12T11:05:19.032Z] [BatchSync] Processing success item: ID=280, SKU=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T11:05:19.032Z] [BatchSync] Found local product ID=561 for SKU=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T11:05:19.035Z] [BatchSync] Creating ProductSiteSku for productId=561 code=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T11:05:48.915Z] [BatchSync] Starting sync to site 1 for products: [561,560] +[2025-12-12T11:05:48.923Z] [BatchSync] Found 2 products in local DB +[2025-12-12T11:05:48.931Z] [BatchSync] Payload - Create: 0, Update: 2 +[2025-12-12T11:05:48.932Z] [BatchSync] Update Payload: [{"id":"279","name":"Pablo-Mango lce-30MG-wet","type":"simple","regular_price":"0.00","sale_price":"0.00","sku":"SPPablo-Mango lce-30MG-wet","status":"publish"},{"id":"280","name":"YOONE- ICE WINTERGREEN-9MG-wet","type":"simple","regular_price":"0.00","sale_price":"0.00","sku":"SPYOONE- ICE WINTERGREEN-9MG-wet","status":"publish"}] +[2025-12-12T11:05:49.703Z] [BatchSync] API Success. Result: {"update":[{"id":279,"name":"Pablo-Mango lce-30MG-wet","slug":"pablo-mango-lce-30mg-wet","permalink":"http://simple.local/product/pablo-mango-lce-30mg-wet/","date_created":"2025-12-12T10:44:40","date_created_gmt":"2025-12-12T10:44:40","date_modified":"2025-12-12T11:05:49","date_modified_gmt":"2025-12-12T11:05:49","type":"simple","status":"publish","featured":false,"catalog_visibility":"visible","description":"","short_description":"","sku":"SPPablo-Mango lce-30MG-wet","price":"0.00","regular_price":"0.00","sale_price":"","date_on_sale_from":null,"date_on_sale_from_gmt":null,"date_on_sale_to":null,"date_on_sale_to_gmt":null,"on_sale":false,"purchasable":true,"total_sales":0,"virtual":false,"downloadable":false,"downloads":[],"download_limit":-1,"download_expiry":-1,"external_url":"","button_text":"","tax_status":"taxable","tax_class":"","manage_stock":false,"stock_quantity":null,"backorders":"no","backorders_allowed":false,"backordered":false,"low_stock_amount":null,"sold_individually":false,"weight":"","dimensions":{"length":"","width":"","height":""},"shipping_required":true,"shipping_taxable":true,"shipping_class":"","shipping_class_id":0,"reviews_allowed":true,"average_rating":"0","rating_count":0,"upsell_ids":[],"cross_sell_ids":[],"parent_id":0,"purchase_note":"","categories":[{"id":15,"name":"Uncategorized","slug":"uncategorized"}],"brands":[],"tags":[],"images":[],"attributes":[],"default_attributes":[],"variations":[],"grouped_products":[],"menu_order":0,"price_html":"$0.00 available on subscription","related_ids":[64,280,276,271,60],"meta_data":[{"key":"_subscription_payment_sync_date","value":0}],"stock_status":"instock","has_options":false,"post_password":"","global_unique_id":"","permalink_template":"http://simple.local/product/%pagename%/","generated_slug":"pablo-mango-lce-30mg-wet","bundled_by":[],"bundle_stock_status":"instock","bundle_stock_quantity":null,"bundle_virtual":false,"bundle_layout":"","bundle_add_to_cart_form_location":"","bundle_editable_in_cart":false,"bundle_sold_individually_context":"","bundle_item_grouping":"","bundle_min_size":"","bundle_max_size":"","bundled_items":[],"bundle_sell_ids":[],"_links":{"self":[{"href":"http://simple.local/wp-json/wc/v3/products/279","targetHints":{"allow":["GET","POST","PUT","PATCH","DELETE"]}}],"collection":[{"href":"http://simple.local/wp-json/wc/v3/products"}]}},{"id":280,"name":"YOONE- ICE WINTERGREEN-9MG-wet","slug":"yoone-ice-wintergreen-9mg-wet","permalink":"http://simple.local/product/yoone-ice-wintergreen-9mg-wet/","date_created":"2025-12-12T10:44:40","date_created_gmt":"2025-12-12T10:44:40","date_modified":"2025-12-12T11:05:49","date_modified_gmt":"2025-12-12T11:05:49","type":"simple","status":"publish","featured":false,"catalog_visibility":"visible","description":"","short_description":"","sku":"SPYOONE- ICE WINTERGREEN-9MG-wet","price":"0.00","regular_price":"0.00","sale_price":"","date_on_sale_from":null,"date_on_sale_from_gmt":null,"date_on_sale_to":null,"date_on_sale_to_gmt":null,"on_sale":false,"purchasable":true,"total_sales":0,"virtual":false,"downloadable":false,"downloads":[],"download_limit":-1,"download_expiry":-1,"external_url":"","button_text":"","tax_status":"taxable","tax_class":"","manage_stock":false,"stock_quantity":null,"backorders":"no","backorders_allowed":false,"backordered":false,"low_stock_amount":null,"sold_individually":false,"weight":"","dimensions":{"length":"","width":"","height":""},"shipping_required":true,"shipping_taxable":true,"shipping_class":"","shipping_class_id":0,"reviews_allowed":true,"average_rating":"0","rating_count":0,"upsell_ids":[],"cross_sell_ids":[],"parent_id":0,"purchase_note":"","categories":[{"id":15,"name":"Uncategorized","slug":"uncategorized"}],"brands":[],"tags":[],"images":[],"attributes":[],"default_attributes":[],"variations":[],"grouped_products":[],"menu_order":0,"price_html":"$0.00 available on subscription","related_ids":[60,81,279,64,276],"meta_data":[{"key":"_subscription_payment_sync_date","value":0}],"stock_status":"instock","has_options":false,"post_password":"","global_unique_id":"","permalink_template":"http://simple.local/product/%pagename%/","generated_slug":"yoone-ice-wintergreen-9mg-wet","bundled_by":[],"bundle_stock_status":"instock","bundle_stock_quantity":null,"bundle_virtual":false,"bundle_layout":"","bundle_add_to_cart_form_location":"","bundle_editable_in_cart":false,"bundle_sold_individually_context":"","bundle_item_grouping":"","bundle_min_size":"","bundle_max_size":"","bundled_items":[],"bundle_sell_ids":[],"_links":{"self":[{"href":"http://simple.local/wp-json/wc/v3/products/280","targetHints":{"allow":["GET","POST","PUT","PATCH","DELETE"]}}],"collection":[{"href":"http://simple.local/wp-json/wc/v3/products"}]}}]} +[2025-12-12T11:05:49.704Z] [BatchSync] Processing success item: ID=279, SKU=SPPablo-Mango lce-30MG-wet +[2025-12-12T11:05:49.704Z] [BatchSync] Found local product ID=560 for SKU=SPPablo-Mango lce-30MG-wet +[2025-12-12T11:05:49.709Z] [BatchSync] ProductSiteSku already exists for productId=560 code=SPPablo-Mango lce-30MG-wet +[2025-12-12T11:05:49.738Z] [BatchSync] Processing success item: ID=280, SKU=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T11:05:49.739Z] [BatchSync] Found local product ID=561 for SKU=SPYOONE- ICE WINTERGREEN-9MG-wet +[2025-12-12T11:05:49.741Z] [BatchSync] Creating ProductSiteSku for productId=561 code=SPYOONE- ICE WINTERGREEN-9MG-wet diff --git a/migration-guide.md b/migration-guide.md new file mode 100644 index 0000000..4bb0666 --- /dev/null +++ b/migration-guide.md @@ -0,0 +1,49 @@ +# 数据库迁移指南 + +为了支持区域坐标功能,需要执行数据库迁移操作,将新增的 `latitude` 和 `longitude` 字段添加到数据库表中. + +## 执行迁移步骤 + +### 1. 生成迁移文件 + +运行以下命令生成迁移文件: + +```bash +npm run migration:generate -- ./src/db/migrations/AddCoordinatesToArea +``` + +### 2. 检查迁移文件 + +生成的迁移文件会自动包含添加 `latitude` 和 `longitude` 字段的SQL语句.您可以检查文件内容确保迁移逻辑正确. + +### 3. 执行迁移 + +运行以下命令执行迁移,将更改应用到数据库: + +```bash +npm run migration:run +``` + +## 手动迁移SQL(可选) + +如果需要手动执行SQL,可以使用以下语句: + +```sql +ALTER TABLE `area` +ADD COLUMN `latitude` DECIMAL(10,6) NULL AFTER `name`, +ADD COLUMN `longitude` DECIMAL(10,6) NULL AFTER `latitude`; +``` + +## 回滚迁移(如需) + +如果遇到问题,可以使用以下命令回滚迁移: + +```bash +npm run typeorm -- migration:revert -d src/db/datasource.ts +``` + +## 注意事项 + +- 确保在执行迁移前备份数据库 +- 迁移不会影响现有数据,新增字段默认为 NULL +- 迁移后,可以通过API开始为区域添加坐标信息 \ No newline at end of file diff --git a/output.log b/output.log new file mode 100644 index 0000000..db350f0 --- /dev/null +++ b/output.log @@ -0,0 +1,24 @@ + +> my-midway-project@1.0.0 dev +> cross-env NODE_ENV=local mwtsc --watch --run @midwayjs/mock/app.js + + +[10:37:17 AM] Starting compilation in watch mode... + + + + + + +[10:37:19 AM] Found 0 errors. Watching for file changes. + +2025-12-01 10:37:20.106 INFO 58678 [SyncProductJob] start job SyncProductJob +2025-12-01 10:37:20.106 INFO 58678 [SyncShipmentJob] start job SyncShipmentJob +2025-12-01 10:37:20.109 INFO 58678 [SyncProductJob] complete job SyncProductJob + +Node.js server started in 732 ms + +➜ Local: http://127.0.0.1:7001/ +➜ Network: http://192.168.5.100:7001/  + +2025-12-01 10:37:20.110 INFO 58678 [SyncShipmentJob] complete job SyncShipmentJob diff --git a/package-lock.json b/package-lock.json index 0ede98f..e207cd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,21 +20,33 @@ "@midwayjs/logger": "^3.1.0", "@midwayjs/swagger": "^3.20.2", "@midwayjs/typeorm": "^3.20.0", + "@midwayjs/upload": "^3.20.16", "@midwayjs/validate": "^3.20.2", - "axios": "^1.7.9", + "@woocommerce/woocommerce-rest-api": "^1.0.2", + "axios": "^1.13.2", "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", + "csv-parse": "^6.1.0", "dayjs": "^1.11.13", - "mysql2": "^3.11.5", + "eta": "^4.4.1", + "i18n-iso-countries": "^7.14.0", + "mysql2": "^3.15.3", "nodemailer": "^7.0.5", + "npm-check-updates": "^19.1.2", + "qs": "^6.14.0", + "sharp": "^0.33.3", "swagger-ui-dist": "^5.18.2", - "typeorm": "^0.3.20", + "typeorm": "^0.3.27", + "typeorm-extension": "^3.7.2", + "wpapi": "^1.2.2", + "xlsx": "^0.18.5", "xml2js": "^0.6.2" }, "devDependencies": { "@midwayjs/mock": "^3.20.11", "cross-env": "^10.1.0", "mwtsc": "^1.15.2", + "tsx": "^4.20.6", "typescript": "^5.9.3" }, "engines": { @@ -52,6 +64,16 @@ "node": ">=0.1.90" } }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -59,6 +81,465 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@faker-js/faker": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", + "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@hapi/bourne": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/@hapi/bourne/-/bourne-3.0.0.tgz", @@ -80,6 +561,367 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -347,6 +1189,19 @@ "node": ">=12" } }, + "node_modules/@midwayjs/upload": { + "version": "3.20.16", + "resolved": "https://registry.npmjs.org/@midwayjs/upload/-/upload-3.20.16.tgz", + "integrity": "sha512-lnHDOeU4wGvABJjYjSR5dpG+6f4+hxJhU+1TsT+OsrKMDju6AyTOnTSFa1V1vMO7+VplKzkWP+ZFJkAVI0Buuw==", + "license": "MIT", + "dependencies": { + "file-type": "16.5.4", + "raw-body": "2.5.2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@midwayjs/validate": { "version": "3.20.13", "resolved": "https://registry.npmmirror.com/@midwayjs/validate/-/validate-3.20.13.tgz", @@ -377,7 +1232,6 @@ "version": "2.1.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -391,7 +1245,6 @@ "version": "2.0.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -401,7 +1254,6 @@ "version": "1.2.8", "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -465,6 +1317,12 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/accepts": { "version": "1.3.7", "resolved": "https://registry.npmmirror.com/@types/accepts/-/accepts-1.3.7.tgz", @@ -675,6 +1533,33 @@ "@types/superagent": "*" } }, + "node_modules/@woocommerce/woocommerce-rest-api": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@woocommerce/woocommerce-rest-api/-/woocommerce-rest-api-1.0.2.tgz", + "integrity": "sha512-G+0VwM0MINF83KnT7Rg/htm9EEYADWvDPT/UWEJdZ0de1vXvsPrr4M1ksKaxgKHO8qIJViRrIHCtrui2JoVA+Q==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.8", + "create-hmac": "^1.1.7", + "oauth-1.0a": "^2.2.6", + "url-parse": "^1.4.7" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -688,6 +1573,15 @@ "node": ">= 0.6" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -799,9 +1693,9 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -867,7 +1761,6 @@ "version": "3.0.3", "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -982,6 +1875,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", @@ -1017,6 +1923,20 @@ "node": ">=18" } }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz", @@ -1197,6 +2117,28 @@ "node": ">=8.0.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", @@ -1215,6 +2157,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1248,12 +2200,20 @@ "version": "1.3.1", "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1279,7 +2239,6 @@ "version": "2.1.4", "resolved": "https://registry.npmmirror.com/cookiejar/-/cookiejar-2.1.4.tgz", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, "license": "MIT" }, "node_modules/cookies": { @@ -1301,6 +2260,51 @@ "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, "node_modules/cron": { "version": "3.5.0", "resolved": "https://registry.npmmirror.com/cron/-/cron-3.5.0.tgz", @@ -1343,6 +2347,12 @@ "node": ">= 8" } }, + "node_modules/csv-parse": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz", + "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==", + "license": "MIT" + }, "node_modules/dayjs": { "version": "1.11.18", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz", @@ -1436,6 +2446,12 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", @@ -1446,6 +2462,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/dezalgo/-/dezalgo-1.0.4.tgz", @@ -1457,6 +2482,12 @@ "wrappy": "1" } }, + "node_modules/diacritics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", + "integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1502,6 +2533,12 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ebec": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ebec/-/ebec-2.3.0.tgz", + "integrity": "sha512-bt+0tSL7223VU3PSVi0vtNLZ8pO1AfWolcPPMk2a/a5H+o/ZU9ky0n3A0zhrR4qzJTN61uPsGIO4ShhOukdzxA==", + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1532,6 +2569,18 @@ "node": ">= 0.8" } }, + "node_modules/envix": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/envix/-/envix-1.5.0.tgz", + "integrity": "sha512-IOxTKT+tffjxgvX2O5nq6enbkv6kBQ/QdMy18bZWo0P0rKPvsRp2/EypIPwTvJfnmk3VdOlq/KcRSZCswefM/w==", + "license": "MIT", + "dependencies": { + "std-env": "^3.7.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1577,6 +2626,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", @@ -1592,11 +2683,40 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/eta": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.4.1.tgz", + "integrity": "sha512-4o6fYxhRmFmO9SJcU9PxBLYPGapvJ/Qha0ZE+Y6UE9QIUd0Wk1qaLISQ6J1bM7nOcWHhs1YmY3mfrfwkJRBTWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -1620,17 +2740,32 @@ "version": "1.19.1", "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -1639,6 +2774,15 @@ "node": ">=8" } }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1722,6 +2866,15 @@ "url": "https://ko-fi.com/tunnckoCore/commissions" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", @@ -1773,6 +2926,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1847,7 +3012,6 @@ "version": "5.1.2", "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -1928,6 +3092,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", @@ -2003,6 +3182,18 @@ "node": ">= 0.8" } }, + "node_modules/i18n-iso-countries": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz", + "integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==", + "license": "MIT", + "dependencies": { + "diacritics": "1.3.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -2064,6 +3255,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2093,7 +3290,6 @@ "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2130,7 +3326,6 @@ "version": "4.0.3", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -2143,7 +3338,6 @@ "version": "7.0.0", "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -2215,6 +3409,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/joi": { "version": "17.13.3", "resolved": "https://registry.npmmirror.com/joi/-/joi-17.13.3.tgz", @@ -2397,6 +3600,29 @@ "node": ">= 0.6" } }, + "node_modules/li": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/li/-/li-1.3.0.tgz", + "integrity": "sha512-z34TU6GlMram52Tss5mt1m//ifRIpKH5Dqm7yUVOdHI+BQCs9qGPHFaCUTIzsWX7edN30aa2WrPwR7IO10FHaw==", + "license": "MIT" + }, + "node_modules/locter": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/locter/-/locter-2.2.1.tgz", + "integrity": "sha512-Cc7mowptFl7ug5he6Iuos7aGRd9xbwTfnx1ng4AX/7F4iqemPaXAIJDi13IBwQZrKgli9OPEYXm6uCKr7ynxUQ==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "ebec": "^2.3.0", + "fast-glob": "^3.3.3", + "flat": "^5.0.2", + "jiti": "^2.6.1", + "yaml": "^2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2445,6 +3671,15 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz", @@ -2487,6 +3722,17 @@ "node": ">= 0.4" } }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", @@ -2500,7 +3746,6 @@ "version": "1.4.1", "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2519,7 +3764,6 @@ "version": "4.0.8", "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -2533,7 +3777,6 @@ "version": "2.6.0", "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -2659,9 +3902,9 @@ } }, "node_modules/mysql2": { - "version": "3.15.0", - "resolved": "https://registry.npmmirror.com/mysql2/-/mysql2-3.15.0.tgz", - "integrity": "sha512-tT6pomf5Z/I7Jzxu8sScgrYBMK9bUFWd7Kbo6Fs1L0M13OOIJ/ZobGKS3Z7tQ8Re4lj+LnLXIQVZZxa3fhYKzA==", + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -2699,6 +3942,16 @@ "node": ">= 0.6" } }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/nodemailer": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-7.0.6.tgz", @@ -2718,6 +3971,26 @@ "node": ">=0.10.0" } }, + "node_modules/npm-check-updates": { + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.1.2.tgz", + "integrity": "sha512-FNeFCVgPOj0fz89hOpGtxP2rnnRHR7hD2E8qNU8SMWfkyDZXA/xpgjsL3UMLSo3F/K13QvJDnbxPngulNDDo/g==", + "license": "Apache-2.0", + "bin": { + "ncu": "build/cli.js", + "npm-check-updates": "build/cli.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=8.12.1" + } + }, + "node_modules/oauth-1.0a": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", + "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==", + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2763,6 +4036,15 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/parse-link-header": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-link-header/-/parse-link-header-1.0.1.tgz", + "integrity": "sha512-Z0gpfHmwCIKDr5rRzjypL+p93aHVWO7e+0rFcUl9E3sC67njjs+xHFenuboSXZGlvYtmQqRzRaE3iFpTUnLmFQ==", + "license": "MIT", + "dependencies": { + "xtend": "~4.0.1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", @@ -2772,6 +4054,16 @@ "node": ">= 0.8" } }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", @@ -2819,6 +4111,19 @@ "node": ">=8" } }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", @@ -2853,6 +4158,21 @@ "node": ">= 0.4" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -2861,7 +4181,7 @@ }, "node_modules/qs": { "version": "6.14.0", - "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { @@ -2874,6 +4194,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-lit": { "version": "1.5.2", "resolved": "https://registry.npmmirror.com/queue-lit/-/queue-lit-1.5.2.tgz", @@ -2888,7 +4214,6 @@ "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -2905,6 +4230,25 @@ ], "license": "MIT" }, + "node_modules/rapiq": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/rapiq/-/rapiq-0.9.0.tgz", + "integrity": "sha512-k4oT4RarFBrlLMJ49xUTeQpa/us0uU4I70D/UEnK3FWQ4GENzei01rEQAmvPKAIzACo4NMW+YcYJ7EVfSa7EFg==", + "license": "MIT", + "dependencies": { + "ebec": "^1.1.0", + "smob": "^1.4.0" + } + }, + "node_modules/rapiq/node_modules/ebec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ebec/-/ebec-1.1.1.tgz", + "integrity": "sha512-JZ1vcvPQtR+8LGbZmbjG21IxLQq/v47iheJqn2F6yB2CgnGfn8ZVg3myHrf3buIZS8UCwQK0jOSIb3oHX7aH8g==", + "license": "MIT", + "dependencies": { + "smob": "^1.4.0" + } + }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz", @@ -2932,6 +4276,74 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", @@ -2960,6 +4372,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2974,18 +4392,29 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -3129,6 +4558,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3240,6 +4708,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", @@ -3250,6 +4727,12 @@ "node": ">=8" } }, + "node_modules/smob": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", @@ -3296,6 +4779,18 @@ "node": ">= 0.6" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz", @@ -3305,6 +4800,27 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", @@ -3401,6 +4917,23 @@ "node": ">=8" } }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmmirror.com/superagent/-/superagent-8.1.2.tgz", @@ -3467,9 +5000,9 @@ } }, "node_modules/to-buffer": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/to-buffer/-/to-buffer-1.2.1.tgz", - "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", "license": "MIT", "dependencies": { "isarray": "^2.0.5", @@ -3484,7 +5017,6 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -3502,6 +5034,23 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tsc-alias": { "version": "1.8.16", "resolved": "https://registry.npmmirror.com/tsc-alias/-/tsc-alias-1.8.16.tgz", @@ -3539,6 +5088,26 @@ "node": ">=0.6.x" } }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", @@ -3668,6 +5237,113 @@ } } }, + "node_modules/typeorm-extension": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/typeorm-extension/-/typeorm-extension-3.7.2.tgz", + "integrity": "sha512-OGxx9RYqxohyfZnfr8JEGEcO3KCNDmh+fSbgxrNH2/pBN7Deprv5M+BmKIFjbNRi0mUHW4d0yx7ALoDUxU87Pg==", + "license": "MIT", + "dependencies": { + "consola": "^3.4.0", + "envix": "^1.5.0", + "locter": "^2.2.1", + "pascal-case": "^3.1.2", + "rapiq": "^0.9.0", + "reflect-metadata": "^0.2.2", + "smob": "^1.5.0", + "yargs": "^18.0.0" + }, + "bin": { + "typeorm-extension": "bin/cli.cjs", + "typeorm-extension-esm": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0" + }, + "peerDependencies": { + "@faker-js/faker": ">=8.4.1", + "typeorm": "~0.3.0" + } + }, + "node_modules/typeorm-extension/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typeorm-extension/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/typeorm-extension/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typeorm-extension/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/typeorm-extension/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/typeorm-extension/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3697,6 +5373,22 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", @@ -3755,6 +5447,98 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wpapi": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/wpapi/-/wpapi-1.2.2.tgz", + "integrity": "sha512-lkgi8Gjav3SArrCkNpG61ZnmCyamXKB+SjaR8tAoHhSZbJRTeabIlsdqUUAN3JGbVY3ht8p+EGdpCFIaanI5+w==", + "license": "MIT", + "dependencies": { + "li": "^1.3.0", + "parse-link-header": "^1.0.1", + "qs": "^6.6.0", + "superagent": "^4.0.0" + } + }, + "node_modules/wpapi/node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/wpapi/node_modules/formidable": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", + "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", + "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/wpapi/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/wpapi/node_modules/superagent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-4.1.0.tgz", + "integrity": "sha512-FT3QLMasz0YyCd4uIi5HNe+3t/onxMyEho7C3PSqmti3Twgy2rXT4fmkTz6wRL6bTF4uzPcfkUCa8u4JWHw8Ag==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "license": "MIT", + "dependencies": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.2", + "debug": "^4.1.0", + "form-data": "^2.3.3", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^2.4.0", + "qs": "^6.6.0", + "readable-stream": "^3.0.6" + }, + "engines": { + "node": ">= 6.0" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -3853,6 +5637,27 @@ "dev": true, "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz", @@ -3875,6 +5680,15 @@ "node": ">=4.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", @@ -3894,6 +5708,18 @@ "node": ">=18" } }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index d8b9c90..17bde91 100644 --- a/package.json +++ b/package.json @@ -15,16 +15,26 @@ "@midwayjs/logger": "^3.1.0", "@midwayjs/swagger": "^3.20.2", "@midwayjs/typeorm": "^3.20.0", + "@midwayjs/upload": "^3.20.16", "@midwayjs/validate": "^3.20.2", "@woocommerce/woocommerce-rest-api": "^1.0.2", - "axios": "^1.7.9", + "axios": "^1.13.2", "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", + "csv-parse": "^6.1.0", "dayjs": "^1.11.13", - "mysql2": "^3.11.5", + "eta": "^4.4.1", + "i18n-iso-countries": "^7.14.0", + "mysql2": "^3.15.3", "nodemailer": "^7.0.5", + "npm-check-updates": "^19.1.2", + "qs": "^6.14.0", + "sharp": "^0.33.3", "swagger-ui-dist": "^5.18.2", - "typeorm": "^0.3.20", + "typeorm": "^0.3.27", + "typeorm-extension": "^3.7.2", + "wpapi": "^1.2.2", + "xlsx": "^0.18.5", "xml2js": "^0.6.2" }, "engines": { @@ -36,10 +46,15 @@ "dev": "cross-env NODE_ENV=local mwtsc --watch --run @midwayjs/mock/app.js", "test": "cross-env NODE_ENV=unittest jest", "cov": "jest --coverage", - "lint": "mwts check", - "lint:fix": "mwts fix", + "lint": "mwtsc check", + "lint:fix": "mwtsc fix", "ci": "npm run cov", - "build": "mwtsc --cleanOutDir" + "build": "mwtsc --cleanOutDir", + "seed": "ts-node src/db/seed/index.ts", + "seed:run": "ts-node ./node_modules/typeorm-extension/bin/cli.cjs seed:run -d src/db/datasource.ts", + "typeorm": "ts-node ./node_modules/typeorm/cli.js", + "migration:generate": "npm run typeorm -- -d src/db/datasource.ts migration:generate", + "migration:run": "npm run typeorm -- migration:run -d src/db/datasource.ts" }, "repository": { "type": "git", @@ -51,6 +66,7 @@ "@midwayjs/mock": "^3.20.11", "cross-env": "^10.1.0", "mwtsc": "^1.15.2", + "tsx": "^4.20.6", "typescript": "^5.9.3" } } diff --git a/permutation_fix.md b/permutation_fix.md new file mode 100644 index 0000000..de227d0 --- /dev/null +++ b/permutation_fix.md @@ -0,0 +1,184 @@ +# Permutation页面列表显示问题分析和修复方案 + +## 问题分析 + +经过代码分析,发现了以下几个可能导致列表不显示的问题: + +### 1. API路径不匹配 +前端代码中引用的API函数名与后端控制器中的路径不一致: +- 前端:`productcontrollerGetcategoriesall`、`productcontrollerGetcategoryattributes`、`productcontrollerGetproductlist` +- 后端实际的API路径:`/product/categories/all`、`/product/category/:id/attributes`、`/product/list` + +### 2. 数据格式问题 +- `getCategoryAttributes`返回的数据结构与前端期望的不匹配 +- 属性值获取逻辑可能存在问题 + +### 3. 组合生成逻辑问题 +- 在生成排列组合时,数据结构和键值对应可能不正确 + +## 修复方案 + +### 后端修复 + +1. **修改getCategoryAttributes方法** - 在`/Users/zksu/Developer/work/workcode/API/src/service/product.service.ts`中: + +```typescript +// 获取分类下的属性配置 +async getCategoryAttributes(categoryId: number): Promise { + const category = await this.categoryModel.findOne({ + where: { id: categoryId }, + relations: ['attributes', 'attributes.attributeDict', 'attributes.attributeDict.items'], + }); + + if (!category) { + return []; + } + + // 格式化返回,匹配前端期望的数据结构 + return category.attributes.map(attr => ({ + id: attr.id, + dictId: attr.attributeDict.id, + name: attr.attributeDict.name, // 用于generateKeyFromPermutation + title: attr.attributeDict.title, // 用于列标题 + dict: { + id: attr.attributeDict.id, + name: attr.attributeDict.name, + title: attr.attributeDict.title, + items: attr.attributeDict.items || [] + } + })); +} +``` + +2. **确保dict/items接口可用** - 检查字典项获取接口: + +在`/Users/zksu/Developer/work/workcode/API/src/controller/dict.controller.ts`中添加或确认: + +```typescript +@Get('/items') +async getDictItems(@Query('dictId') dictId: number) { + try { + const dict = await this.dictModel.findOne({ + where: { id: dictId }, + relations: ['items'] + }); + + if (!dict) { + return []; + } + + return dict.items || []; + } catch (error) { + return errorResponse(error?.message || error); + } +} +``` + +### 前端修复建议 + +1. **添加错误处理和调试信息**: + +```typescript +// 在获取属性值的地方添加错误处理 +const fetchData = async () => { + setLoading(true); + try { + // 1. Fetch Attributes + const attrRes = await productcontrollerGetcategoryattributes({ + id: categoryId, + }); + console.log('Attributes response:', attrRes); // 调试用 + const attrs = Array.isArray(attrRes) ? attrRes : attrRes?.data || []; + setAttributes(attrs); + + // 2. Fetch Attribute Values (Dict Items) + const valuesMap: Record = {}; + for (const attr of attrs) { + const dictId = attr.dict?.id || attr.dictId; + if (dictId) { + try { + const itemsRes = await request('/dict/items', { + params: { dictId }, + }); + console.log(`Dict items for ${attr.name}:`, itemsRes); // 调试用 + valuesMap[attr.name] = itemsRes || []; + } catch (error) { + console.error(`Failed to fetch items for dict ${dictId}:`, error); + valuesMap[attr.name] = []; + } + } + } + setAttributeValues(valuesMap); + + // 3. Fetch Existing Products + await fetchProducts(categoryId); + } catch (error) { + console.error('Error in fetchData:', error); + message.error('获取数据失败'); + } finally { + setLoading(false); + } +}; +``` + +2. **修复组合生成逻辑**: + +```typescript +// 修改generateKeyFromPermutation函数 +const generateKeyFromPermutation = (perm: any) => { + const parts = Object.keys(perm).map((attrName) => { + const valItem = perm[attrName]; + const val = valItem.name || valItem.value; // 兼容不同的数据格式 + return `${attrName}:${val}`; + }); + return parts.sort().join('|'); +}; + +// 修改generateAttributeKey函数 +const generateAttributeKey = (attrs: any[]) => { + const parts = attrs.map((a) => { + const key = a.dict?.name || a.dictName || a.name; + const val = a.name || a.value; + return `${key}:${val}`; + }); + return parts.sort().join('|'); +}; +``` + +3. **添加空状态处理**: + +```typescript +// 在ProTable中添加空状态提示 + +``` + +## 调试步骤 + +1. **检查网络请求**: + - 打开浏览器开发者工具 + - 检查 `/product/categories/all` 请求是否成功 + - 检查 `/product/category/:id/attributes` 请求返回的数据格式 + - 检查 `/dict/items?dictId=:id` 请求是否成功 + - 检查 `/product/list` 请求是否成功 + +2. **检查控制台日志**: + - 查看属性数据是否正确加载 + - 查看属性值是否正确获取 + - 查看排列组合是否正确生成 + +3. **检查数据结构**: + - 确认 `attributes` 数组是否正确 + - 确认 `attributeValues` 对象是否正确填充 + - 确认 `permutations` 数组是否正确生成 + +## 测试验证 + +1. 选择一个有属性配置的分类 +2. 确认属性有对应的字典项 +3. 检查排列组合是否正确显示 +4. 验证现有产品匹配是否正确 \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7fd255..ff03e1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,12 +65,18 @@ importers: nodemailer: specifier: ^7.0.5 version: 7.0.10 + npm-check-updates: + specifier: ^19.1.2 + version: 19.1.2 swagger-ui-dist: specifier: ^5.18.2 version: 5.30.2 typeorm: specifier: ^0.3.20 version: 0.3.27(mysql2@3.15.3)(reflect-metadata@0.2.2) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 xml2js: specifier: ^0.6.2 version: 0.6.2 @@ -84,6 +90,9 @@ importers: mwtsc: specifier: ^1.15.2 version: 1.15.2 + tsx: + specifier: ^4.20.6 + version: 4.20.6 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -97,6 +106,162 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hapi/bourne@3.0.0': resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} @@ -314,6 +479,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -415,6 +584,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -446,6 +619,10 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -488,6 +665,11 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + create-hash@1.2.0: resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} @@ -606,6 +788,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -651,6 +838,10 @@ packages: formidable@2.1.5: resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -961,6 +1152,11 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-check-updates@19.1.2: + resolution: {integrity: sha512-FNeFCVgPOj0fz89hOpGtxP2rnnRHR7hD2E8qNU8SMWfkyDZXA/xpgjsL3UMLSo3F/K13QvJDnbxPngulNDDo/g==} + engines: {node: '>=20.0.0', npm: '>=8.12.1'} + hasBin: true + oauth-1.0a@2.2.6: resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==} @@ -1160,6 +1356,10 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -1228,6 +1428,11 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -1327,6 +1532,14 @@ packages: engines: {node: '>= 8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1338,6 +1551,11 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -1373,6 +1591,84 @@ snapshots: '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@hapi/bourne@3.0.0': {} '@hapi/hoek@9.3.0': {} @@ -1645,6 +1941,8 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + adler-32@1.3.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -1735,6 +2033,11 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -1779,6 +2082,8 @@ snapshots: co@4.6.0: {} + codepage@1.15.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1812,6 +2117,8 @@ snapshots: core-util-is@1.0.3: {} + crc-32@1.2.2: {} + create-hash@1.2.0: dependencies: cipher-base: 1.0.7 @@ -1919,6 +2226,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -1967,6 +2303,8 @@ snapshots: once: 1.4.0 qs: 6.14.0 + frac@1.1.2: {} + fresh@0.5.2: {} fsevents@2.3.3: @@ -2313,6 +2651,8 @@ snapshots: normalize-path@3.0.0: {} + npm-check-updates@19.1.2: {} + oauth-1.0a@2.2.6: {} object-inspect@1.13.4: {} @@ -2494,6 +2834,10 @@ snapshots: sqlstring@2.3.3: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + statuses@1.5.0: {} statuses@2.0.1: {} @@ -2582,6 +2926,13 @@ snapshots: tsscmp@1.0.6: {} + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -2647,6 +2998,10 @@ snapshots: dependencies: isexe: 2.0.0 + wmf@1.0.2: {} + + word@0.3.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -2661,6 +3016,16 @@ snapshots: wrappy@1.0.2: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + xml2js@0.6.2: dependencies: sax: 1.4.3 diff --git a/src/adapter/shopyy.adapter.ts b/src/adapter/shopyy.adapter.ts new file mode 100644 index 0000000..b210e4a --- /dev/null +++ b/src/adapter/shopyy.adapter.ts @@ -0,0 +1,678 @@ +import { ISiteAdapter } from '../interface/site-adapter.interface'; +import { ShopyyService } from '../service/shopyy.service'; +import { + UnifiedAddressDTO, + UnifiedCustomerDTO, + UnifiedMediaDTO, + UnifiedOrderDTO, + UnifiedOrderLineItemDTO, + UnifiedPaginationDTO, + UnifiedProductDTO, + UnifiedProductVariationDTO, + UnifiedSearchParamsDTO, + UnifiedSubscriptionDTO, + UnifiedReviewPaginationDTO, + UnifiedReviewDTO, + UnifiedWebhookDTO, + UnifiedWebhookPaginationDTO, + CreateWebhookDTO, + UpdateWebhookDTO, +} from '../dto/site-api.dto'; +import { + ShopyyCustomer, + ShopyyOrder, + ShopyyProduct, + ShopyyVariant, + ShopyyWebhook, +} from '../dto/shopyy.dto'; + +export class ShopyyAdapter implements ISiteAdapter { + constructor(private site: any, private shopyyService: ShopyyService) { + this.mapCustomer = this.mapCustomer.bind(this); + this.mapProduct = this.mapProduct.bind(this); + this.mapVariation = this.mapVariation.bind(this); + this.mapOrder = this.mapOrder.bind(this); + this.mapMedia = this.mapMedia.bind(this); + // this.mapSubscription = this.mapSubscription.bind(this); + } + + private mapMedia(item: any): UnifiedMediaDTO { + // 映射媒体项目 + return { + id: item.id, + date_created: item.created_at, + date_modified: item.updated_at, + source_url: item.src, + title: item.alt || '', + media_type: '', // Shopyy API未提供,暂时留空 + mime_type: '', // Shopyy API未提供,暂时留空 + }; + } + + private mapMediaSearchParams(params: UnifiedSearchParamsDTO): any { + const { search, page, per_page } = params; + const shopyyParams: any = { + page: page || 1, + limit: per_page || 10, + }; + + if (search) { + shopyyParams.query = search; + } + + return shopyyParams; + } + + private mapProduct(item: ShopyyProduct & { permalink?: string }): UnifiedProductDTO { + // 映射产品状态 + function mapProductStatus(status: number) { + return status === 1 ? 'publish' : 'draft'; + } + return { + id: item.id, + name: item.name || item.title, + type: String(item.product_type ?? ''), + status: mapProductStatus(item.status), + sku: item.variant?.sku || '', + regular_price: String(item.variant?.price ?? ''), + sale_price: String(item.special_price ?? ''), + price: String(item.price ?? ''), + stock_status: item.inventory_tracking === 1 ? 'instock' : 'outofstock', + stock_quantity: item.inventory_quantity, + images: (item.images || []).map((img: any) => ({ + id: img.id || 0, + src: img.src, + name: '', + alt: img.alt || '', + // 排序 + position: img.position || '', + })), + attributes: (item.options || []).map(option => ({ + id: option.id || 0, + name: option.option_name || '', + options: (option.values || []).map(value => value.option_value || ''), + })), + tags: (item.tags || []).map((t: any) => ({ + id: t.id || 0, + name: t.name || '', + })), + // shopyy叫做专辑 + categories: item.collections.map((c: any) => ({ + id: c.id || 0, + name: c.title || '', + })), + variations: item.variants?.map(this.mapVariation.bind(this)) || [], + permalink: item.permalink, + date_created: + typeof item.created_at === 'number' + ? new Date(item.created_at * 1000).toISOString() + : String(item.created_at ?? ''), + date_modified: + typeof item.updated_at === 'number' + ? new Date(item.updated_at * 1000).toISOString() + : String(item.updated_at ?? ''), + raw: item, + }; + } + + private mapVariation(variant: ShopyyVariant): UnifiedProductVariationDTO { + // 映射变体 + return { + id: variant.id, + sku: variant.sku || '', + regular_price: String(variant.price ?? ''), + sale_price: String(variant.special_price ?? ''), + price: String(variant.price ?? ''), + stock_status: + variant.inventory_tracking === 1 ? 'instock' : 'outofstock', + stock_quantity: variant.inventory_quantity, + }; + } + + private mapOrder(item: ShopyyOrder): UnifiedOrderDTO { + // 提取账单和送货地址 如果不存在则为空对象 + const billing = (item as any).billing_address || {}; + const shipping = (item as any).shipping_address || {}; + + // 构建账单地址对象 + const billingObj: UnifiedAddressDTO = { + first_name: billing.first_name || item.firstname || '', + last_name: billing.last_name || item.lastname || '', + fullname: billing.name || `${item.firstname} ${item.lastname}`.trim(), + company: billing.company || '', + email: item.customer_email || item.email || '', + phone: billing.phone || (item as any).telephone || '', + address_1: billing.address1 || item.payment_address || '', + address_2: billing.address2 || '', + city: billing.city || item.payment_city || '', + state: billing.province || item.payment_zone || '', + postcode: billing.zip || item.payment_postcode || '', + country: + billing.country_name || + billing.country_code || + item.payment_country || + '', + }; + + // 构建送货地址对象 + const shippingObj: UnifiedAddressDTO = { + first_name: shipping.first_name || item.firstname || '', + last_name: shipping.last_name || item.lastname || '', + fullname: shipping.name || '', + company: shipping.company || '', + address_1: + shipping.address1 || + (typeof item.shipping_address === 'string' + ? item.shipping_address + : '') || + '', + address_2: shipping.address2 || '', + city: shipping.city || item.shipping_city || '', + state: shipping.province || item.shipping_zone || '', + postcode: shipping.zip || item.shipping_postcode || '', + country: + shipping.country_name || + shipping.country_code || + item.shipping_country || + '', + }; + + // 格式化地址为字符串 + const formatAddress = (addr: UnifiedAddressDTO) => { + return [ + addr.fullname, + addr.company, + addr.address_1, + addr.address_2, + addr.city, + addr.state, + addr.postcode, + addr.country, + addr.phone, + ] + .filter(Boolean) + .join(', '); + }; + + const lineItems: UnifiedOrderLineItemDTO[] = (item.products || []).map( + (p: any) => ({ + id: p.id, + name: p.product_title || p.name, + product_id: p.product_id, + quantity: p.quantity, + total: String(p.price ?? ''), + sku: p.sku || p.sku_code || '', + }) + ); + + return { + id: item.id || item.order_id, + number: item.order_number || item.order_sn, + status: String(item.status || item.order_status), + currency: item.currency_code || item.currency, + total: String(item.total_price ?? item.total_amount ?? ''), + customer_id: item.customer_id || item.user_id, + customer_name: + item.customer_name || `${item.firstname} ${item.lastname}`.trim(), + email: item.customer_email || item.email, + line_items: lineItems, + sales: lineItems, // 兼容前端 + billing: billingObj, + shipping: shippingObj, + billing_full_address: formatAddress(billingObj), + shipping_full_address: formatAddress(shippingObj), + payment_method: item.payment_method, + refunds: [], + date_created: + typeof item.created_at === 'number' + ? new Date(item.created_at * 1000).toISOString() + : item.date_added || + (typeof item.created_at === 'string' ? item.created_at : ''), + date_modified: + typeof item.updated_at === 'number' + ? new Date(item.updated_at * 1000).toISOString() + : item.date_updated || + item.last_modified || + (typeof item.updated_at === 'string' ? item.updated_at : ''), + raw: item, + }; + } + + private mapCustomer(item: ShopyyCustomer): UnifiedCustomerDTO { + // 处理多地址结构 + const addresses = item.addresses || []; + const defaultAddress = item.default_address || (addresses.length > 0 ? addresses[0] : {}); + + // 尝试从地址列表中获取billing和shipping + // 如果没有明确区分,默认使用默认地址或第一个地址 + const billingAddress = defaultAddress; + const shippingAddress = defaultAddress; + + const billing = { + first_name: billingAddress.first_name || item.first_name || '', + last_name: billingAddress.last_name || item.last_name || '', + fullname: billingAddress.name || `${billingAddress.first_name || item.first_name || ''} ${billingAddress.last_name || item.last_name || ''}`.trim(), + company: billingAddress.company || '', + email: item.email || '', + phone: billingAddress.phone || item.contact || '', + address_1: billingAddress.address1 || '', + address_2: billingAddress.address2 || '', + city: billingAddress.city || '', + state: billingAddress.province || '', + postcode: billingAddress.zip || '', + country: billingAddress.country_name || billingAddress.country_code || item.country?.country_name || '' + }; + + const shipping = { + first_name: shippingAddress.first_name || item.first_name || '', + last_name: shippingAddress.last_name || item.last_name || '', + fullname: shippingAddress.name || `${shippingAddress.first_name || item.first_name || ''} ${shippingAddress.last_name || item.last_name || ''}`.trim(), + company: shippingAddress.company || '', + address_1: shippingAddress.address1 || '', + address_2: shippingAddress.address2 || '', + city: shippingAddress.city || '', + state: shippingAddress.province || '', + postcode: shippingAddress.zip || '', + country: shippingAddress.country_name || shippingAddress.country_code || item.country?.country_name || '' + }; + + return { + id: item.id || item.customer_id, + orders: Number(item.orders_count ?? item.order_count ?? item.orders ?? 0), + total_spend: Number(item.total_spent ?? item.total_spend_amount ?? item.total_spend_money ?? 0), + first_name: item.first_name || item.firstname || '', + last_name: item.last_name || item.lastname || '', + fullname: item.fullname || item.customer_name || `${item.first_name || item.firstname || ''} ${item.last_name || item.lastname || ''}`.trim(), + email: item.email || item.customer_email || '', + phone: item.contact || billing.phone || item.phone || '', + billing, + shipping, + date_created: + typeof item.created_at === 'number' + ? new Date(item.created_at * 1000).toISOString() + : (typeof item.created_at === 'string' ? item.created_at : item.date_added || ''), + date_modified: + typeof item.updated_at === 'number' + ? new Date(item.updated_at * 1000).toISOString() + : (typeof item.updated_at === 'string' ? item.updated_at : item.date_updated || ''), + raw: item, + }; + } + + async getProducts( + params: UnifiedSearchParamsDTO + ): Promise> { + const response = await this.shopyyService.fetchResourcePaged( + this.site, + 'products/list', + params + ); + const { items=[], total, totalPages, page, per_page } = response; + const finalItems = items.map((item) => ({ + ...item, + permalink: `${this.site.websiteUrl}/products/${item.handle}`, + })).map(this.mapProduct.bind(this)) + return { + items: finalItems as UnifiedProductDTO[], + total, + totalPages, + page, + per_page, + }; + } + + async getProduct(id: string | number): Promise { + // 使用ShopyyService获取单个产品 + const product = await this.shopyyService.getProduct(this.site, id); + return this.mapProduct(product); + } + + async createProduct(data: Partial): Promise { + const res = await this.shopyyService.createProduct(this.site, data); + return this.mapProduct(res); + } + + async updateProduct(id: string | number, data: Partial): Promise { + // Shopyy update returns boolean? + // shopyyService.updateProduct returns boolean. + // So I can't return the updated product. + // I have to fetch it again or return empty/input. + // Since getProduct is missing, I'll return input data as UnifiedProductDTO (mock). + await this.shopyyService.updateProduct(this.site, String(id), data); + return true; + } + + async updateVariation(productId: string | number, variationId: string | number, data: any): Promise { + await this.shopyyService.updateVariation(this.site, String(productId), String(variationId), data); + return { ...data, id: variationId }; + } + + async getOrderNotes(orderId: string | number): Promise { + return await this.shopyyService.getOrderNotes(this.site, orderId); + } + + async createOrderNote(orderId: string | number, data: any): Promise { + return await this.shopyyService.createOrderNote(this.site, orderId, data); + } + + async deleteProduct(id: string | number): Promise { + // Use batch delete + await this.shopyyService.batchProcessProducts(this.site, { delete: [id] }); + return true; + } + + async batchProcessProducts( + data: { create?: any[]; update?: any[]; delete?: Array } + ): Promise { + return await this.shopyyService.batchProcessProducts(this.site, data); + } + + async getOrders( + params: UnifiedSearchParamsDTO + ): Promise> { + const { items, total, totalPages, page, per_page } = + await this.shopyyService.fetchResourcePaged( + this.site, + 'orders', + params + ); + return { + items: items.map(this.mapOrder.bind(this)), + total, + totalPages, + page, + per_page, + }; + } + + async getOrder(id: string | number): Promise { + const data = await this.shopyyService.getOrder(String(this.site.id), String(id)); + return this.mapOrder(data); + } + + async createOrder(data: Partial): Promise { + const createdOrder = await this.shopyyService.createOrder(this.site, data); + return this.mapOrder(createdOrder); + } + + async updateOrder(id: string | number, data: Partial): Promise { + return await this.shopyyService.updateOrder(this.site, String(id), data); + } + + async deleteOrder(id: string | number): Promise { + return await this.shopyyService.deleteOrder(this.site, id); + } + + async shipOrder(orderId: string | number, data: { + tracking_number?: string; + shipping_provider?: string; + shipping_method?: string; + items?: Array<{ + order_item_id: number; + quantity: number; + }>; + }): Promise { + // 订单发货 + try { + // 更新订单状态为已发货 + await this.shopyyService.updateOrder(this.site, String(orderId), { + status: 'completed', + meta_data: [ + { key: '_tracking_number', value: data.tracking_number }, + { key: '_shipping_provider', value: data.shipping_provider }, + { key: '_shipping_method', value: data.shipping_method } + ] + }); + + // 添加发货备注 + const note = `订单已发货${data.tracking_number ? `,物流单号:${data.tracking_number}` : ''}${data.shipping_provider ? `,物流公司:${data.shipping_provider}` : ''}`; + await this.shopyyService.createOrderNote(this.site, orderId, { note, customer_note: true }); + + return { + success: true, + order_id: orderId, + shipment_id: `shipment_${orderId}_${Date.now()}`, + tracking_number: data.tracking_number, + shipping_provider: data.shipping_provider, + shipped_at: new Date().toISOString() + }; + } catch (error) { + throw new Error(`发货失败: ${error.message}`); + } + } + + async cancelShipOrder(orderId: string | number, data: { + reason?: string; + shipment_id?: string; + }): Promise { + // 取消订单发货 + try { + // 将订单状态改回处理中 + await this.shopyyService.updateOrder(this.site, String(orderId), { + status: 'processing', + meta_data: [ + { key: '_shipment_cancelled', value: 'yes' }, + { key: '_shipment_cancelled_reason', value: data.reason } + ] + }); + + // 添加取消发货的备注 + const note = `订单发货已取消${data.reason ? `,原因:${data.reason}` : ''}`; + await this.shopyyService.createOrderNote(this.site, orderId, { note, customer_note: true }); + + return { + success: true, + order_id: orderId, + shipment_id: data.shipment_id, + reason: data.reason, + cancelled_at: new Date().toISOString() + }; + } catch (error) { + throw new Error(`取消发货失败: ${error.message}`); + } + } + + async getSubscriptions( + params: UnifiedSearchParamsDTO + ): Promise> { + throw new Error('Shopyy does not support subscriptions.'); + } + + async getMedia( + params: UnifiedSearchParamsDTO + ): Promise> { + const requestParams = this.mapMediaSearchParams(params); + const { items, total, totalPages, page, per_page } = await this.shopyyService.fetchResourcePaged( + this.site, + 'media', // Shopyy的媒体API端点可能需要调整 + requestParams + ); + return { + items: items.map(this.mapMedia), + total, + totalPages, + page, + per_page, + }; + } + + async createMedia(file: any): Promise { + const createdMedia = await this.shopyyService.createMedia(this.site, file); + return this.mapMedia(createdMedia); + } + + async updateMedia(id: string | number, data: any): Promise { + const updatedMedia = await this.shopyyService.updateMedia(this.site, id, data); + return this.mapMedia(updatedMedia); + } + + async deleteMedia(id: string | number): Promise { + return await this.shopyyService.deleteMedia(this.site, id); + } + + async getReviews( + params: UnifiedSearchParamsDTO + ): Promise { + const requestParams = this.mapReviewSearchParams(params); + const { items, total, totalPages, page, per_page } = await this.shopyyService.getReviews( + this.site, + requestParams + ); + return { + items: items.map(this.mapReview), + total, + totalPages, + page, + per_page, + }; + } + + async getReview(id: string | number): Promise { + const review = await this.shopyyService.getReview(this.site, id); + return this.mapReview(review); + } + + private mapReview(review: any): UnifiedReviewDTO { + // 将ShopYY评论数据映射到统一评论DTO格式 + return { + id: review.id || review.review_id, + product_id: review.product_id || review.goods_id, + author: review.author_name || review.username || '', + email: review.author_email || review.user_email || '', + content: review.comment || review.content || '', + rating: Number(review.score || review.rating || 0), + status: String(review.status || 'approved'), + date_created: + typeof review.created_at === 'number' + ? new Date(review.created_at * 1000).toISOString() + : String(review.created_at || review.date_added || '') + }; + } + + private mapReviewSearchParams(params: UnifiedSearchParamsDTO): any { + const { search, page, per_page, status } = params; + const shopyyParams: any = { + page: page || 1, + limit: per_page || 10, + }; + + if (search) { + shopyyParams.search = search; + } + + if (status) { + shopyyParams.status = status; + } + + // if (product_id) { + // shopyyParams.product_id = product_id; + // } + + return shopyyParams; + } + + async createReview(data: any): Promise { + const createdReview = await this.shopyyService.createReview(this.site, data); + return this.mapReview(createdReview); + } + + async updateReview(id: string | number, data: any): Promise { + const updatedReview = await this.shopyyService.updateReview(this.site, id, data); + return this.mapReview(updatedReview); + } + + async deleteReview(id: string | number): Promise { + return await this.shopyyService.deleteReview(this.site, id); + } + + // Webhook相关方法 + private mapWebhook(item: ShopyyWebhook): UnifiedWebhookDTO { + return { + id: item.id, + name: item.webhook_name || `Webhook-${item.id}`, + topic: item.event_code || '', + delivery_url: item.url|| '', + status: 'active', + }; + } + + async getWebhooks(params: UnifiedSearchParamsDTO): Promise { + const { items, total, totalPages, page, per_page } = await this.shopyyService.getWebhooks(this.site, params); + return { + items: items.map(this.mapWebhook), + total, + totalPages, + page, + per_page, + }; + } + + async getWebhook(id: string | number): Promise { + const webhook = await this.shopyyService.getWebhook(this.site, id); + return this.mapWebhook(webhook); + } + + async createWebhook(data: CreateWebhookDTO): Promise { + const createdWebhook = await this.shopyyService.createWebhook(this.site, data); + return this.mapWebhook(createdWebhook); + } + + async updateWebhook(id: string | number, data: UpdateWebhookDTO): Promise { + const updatedWebhook = await this.shopyyService.updateWebhook(this.site, id, data); + return this.mapWebhook(updatedWebhook); + } + + async deleteWebhook(id: string | number): Promise { + return await this.shopyyService.deleteWebhook(this.site, id); + } + + async getLinks(): Promise> { + // ShopYY站点的管理后台链接通常基于apiUrl构建 + const url = this.site.websiteUrl + // 提取基础域名,去掉可能的路径部分 + const baseUrl = url.replace(/\/api\/.*$/i, ''); + + const links = [ + { title: '访问网站', url: baseUrl }, + { title: '管理后台', url: `${baseUrl}/admin/` }, + { title: '订单管理', url: `${baseUrl}/admin/orders.htm` }, + { title: '产品管理', url: `${baseUrl}/admin/products.htm` }, + { title: '客户管理', url: `${baseUrl}/admin/customers.htm` }, + { title: '插件管理', url: `${baseUrl}/admin/apps.htm` }, + { title: '店铺设置', url: `${baseUrl}/admin/settings.htm` }, + { title: '营销中心', url: `${baseUrl}/admin/marketing.htm` }, + ]; + return links; + } + + async getCustomers(params: UnifiedSearchParamsDTO): Promise> { + const { items, total, totalPages, page, per_page } = + await this.shopyyService.fetchCustomersPaged(this.site, params); + return { + items: items.map(this.mapCustomer.bind(this)), + total, + totalPages, + page, + per_page + }; + } + + async getCustomer(id: string | number): Promise { + const customer = await this.shopyyService.getCustomer(this.site, id); + return this.mapCustomer(customer); + } + + async createCustomer(data: Partial): Promise { + const createdCustomer = await this.shopyyService.createCustomer(this.site, data); + return this.mapCustomer(createdCustomer); + } + + async updateCustomer(id: string | number, data: Partial): Promise { + const updatedCustomer = await this.shopyyService.updateCustomer(this.site, id, data); + return this.mapCustomer(updatedCustomer); + } + + async deleteCustomer(id: string | number): Promise { + return await this.shopyyService.deleteCustomer(this.site, id); + } +} diff --git a/src/adapter/woocommerce.adapter.ts b/src/adapter/woocommerce.adapter.ts new file mode 100644 index 0000000..64e1168 --- /dev/null +++ b/src/adapter/woocommerce.adapter.ts @@ -0,0 +1,852 @@ +import { ISiteAdapter } from '../interface/site-adapter.interface'; +import { IPlatformService } from '../interface/platform.interface'; +import { + UnifiedMediaDTO, + UnifiedOrderDTO, + UnifiedPaginationDTO, + UnifiedProductDTO, + UnifiedSearchParamsDTO, + UnifiedSubscriptionDTO, + UnifiedCustomerDTO, + UnifiedReviewPaginationDTO, + UnifiedReviewDTO, + UnifiedWebhookDTO, + UnifiedWebhookPaginationDTO, + CreateWebhookDTO, + UpdateWebhookDTO, +} from '../dto/site-api.dto'; +import { + WooProduct, + WooOrder, + WooSubscription, + WpMedia, + WooCustomer, + WooWebhook, + WooOrderSearchParams, + WooProductSearchParams, +} from '../dto/woocommerce.dto'; +import { Site } from '../entity/site.entity'; + +export class WooCommerceAdapter implements ISiteAdapter { + // 构造函数接收站点配置与服务实例 + constructor(private site: Site, private wpService: IPlatformService) { + this.mapProduct = this.mapProduct.bind(this); + this.mapReview = this.mapReview.bind(this); + this.mapCustomer = this.mapCustomer.bind(this); + this.mapMedia = this.mapMedia.bind(this); + this.mapOrder = this.mapOrder.bind(this); + this.mapWebhook = this.mapWebhook.bind(this); + } + + // 映射 WooCommerce webhook 到统一格式 + private mapWebhook(webhook: WooWebhook): UnifiedWebhookDTO { + return { + id: webhook.id.toString(), + name: webhook.name, + status: webhook.status, + topic: webhook.topic, + delivery_url: webhook.delivery_url, + secret: webhook.secret, + api_version: webhook.api_version, + date_created: webhook.date_created, + date_modified: webhook.date_modified, + // metadata: webhook.meta_data || [], + }; + } + + // 获取站点的 webhooks 列表 + async getWebhooks(params: UnifiedSearchParamsDTO): Promise { + try { + const result = await this.wpService.getWebhooks(this.site, params); + + return { + items: (result.items as WooWebhook[]).map(this.mapWebhook), + total: result.total, + page: Number(params.page || 1), + per_page: Number(params.per_page || 20), + totalPages: result.totalPages, + }; + } catch (error) { + throw new Error(`Failed to get webhooks: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // 获取单个 webhook 详情 + async getWebhook(id: string | number): Promise { + try { + const result = await this.wpService.getWebhook(this.site, id); + return this.mapWebhook(result as WooWebhook); + } catch (error) { + throw new Error(`Failed to get webhook: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // 创建新的 webhook + async createWebhook(data: CreateWebhookDTO): Promise { + try { + const params = { + name: data.name, + status: 'active', // 默认状态为活跃 + topic: data.topic, + delivery_url: data.delivery_url, + secret: data.secret, + api_version: data.api_version || 'wp/v2', + }; + const result = await this.wpService.createWebhook(this.site, params); + return this.mapWebhook(result as WooWebhook); + } catch (error) { + throw new Error(`Failed to create webhook: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // 更新现有的 webhook + async updateWebhook(id: string | number, data: UpdateWebhookDTO): Promise { + try { + const params = { + ...(data.name ? { name: data.name } : {}), + ...(data.status ? { status: data.status } : {}), + ...(data.topic ? { topic: data.topic } : {}), + ...(data.delivery_url ? { delivery_url: data.delivery_url } : {}), + ...(data.secret ? { secret: data.secret } : {}), + ...(data.api_version ? { api_version: data.api_version } : {}), + }; + const result = await this.wpService.updateWebhook(this.site, id, params); + return this.mapWebhook(result as WooWebhook); + } catch (error) { + throw new Error(`Failed to update webhook: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // 删除指定的 webhook + async deleteWebhook(id: string | number): Promise { + try { + await this.wpService.deleteWebhook(this.site, id); + return true; + } catch (error) { + throw new Error(`Failed to delete webhook: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async getLinks(): Promise> { + const baseUrl = this.site.apiUrl; + const links = [ + { title: '访问网站', url: baseUrl }, + { title: '管理后台', url: `${baseUrl}/wp-admin/` }, + { title: '订单管理', url: `${baseUrl}/wp-admin/edit.php?post_type=shop_order` }, + { title: '产品管理', url: `${baseUrl}/wp-admin/edit.php?post_type=product` }, + { title: '客户管理', url: `${baseUrl}/wp-admin/users.php` }, + { title: '插件管理', url: `${baseUrl}/wp-admin/plugins.php` }, + { title: '主题管理', url: `${baseUrl}/wp-admin/themes.php` }, + { title: 'WooCommerce设置', url: `${baseUrl}/wp-admin/admin.php?page=wc-settings` }, + { title: 'WooCommerce报告', url: `${baseUrl}/wp-admin/admin.php?page=wc-reports` }, + ]; + return links; + } + + createMedia(file: any): Promise { + throw new Error('Method not implemented.'); + } + batchProcessOrders?(data: { create?: any[]; update?: any[]; delete?: Array; }): Promise { + throw new Error('Method not implemented.'); + } + batchProcessCustomers?(data: { create?: any[]; update?: any[]; delete?: Array; }): Promise { + throw new Error('Method not implemented.'); + } + + + + private mapProductSearchParams(params: UnifiedSearchParamsDTO): Partial { + const page = Number(params.page ?? 1); + const per_page = Number( params.per_page ?? 20); + const where = params.where && typeof params.where === 'object' ? params.where : {}; + let orderby: string | undefined = params.orderby; + let order: 'asc' | 'desc' | undefined = params.orderDir as any; + if (!orderby && params.order && typeof params.order === 'object') { + const entries = Object.entries(params.order as Record); + if (entries.length > 0) { + const [field, dir] = entries[0]; + let mappedField = field; + if (['created_at', 'date_created', 'date'].includes(field)) mappedField = 'date'; + else if (['name', 'title'].includes(field)) mappedField = 'title'; + else if (['id', 'ID'].includes(field)) mappedField = 'id'; + else if (['price', 'regular_price', 'sale_price'].includes(field)) mappedField = 'price'; + orderby = mappedField; + order = String(dir).toLowerCase() === 'desc' ? 'desc' : 'asc'; + } + } + const mapped: any = { + ...(params.search ? { search: params.search } : {}), + ...(params.status ? { status: params.status } : {}), + ...(orderby ? { orderby } : {}), + ...(order ? { order } : {}), + page, + per_page, + }; + + const toArray = (value: any): any[] => { + if (Array.isArray(value)) return value; + if (value === undefined || value === null) return []; + return String(value).split(',').map(v => v.trim()).filter(Boolean); + }; + + if (where.search_fields ?? where.searchFields) mapped.search_fields = toArray(where.search_fields ?? where.searchFields); + if (where.after ?? where.date_created_after ?? where.created_after) mapped.after = String(where.after ?? where.date_created_after ?? where.created_after); + if (where.before ?? where.date_created_before ?? where.created_before) mapped.before = String(where.before ?? where.date_created_before ?? where.created_before); + if (where.modified_after ?? where.date_modified_after) mapped.modified_after = String(where.modified_after ?? where.date_modified_after); + if (where.modified_before ?? where.date_modified_before) mapped.modified_before = String(where.modified_before ?? where.date_modified_before); + if (where.dates_are_gmt ?? where.datesAreGmt) mapped.dates_are_gmt = Boolean(where.dates_are_gmt ?? where.datesAreGmt); + if (where.exclude ?? where.exclude_ids ?? where.excludedIds) mapped.exclude = toArray(where.exclude ?? where.exclude_ids ?? where.excludedIds); + if (where.include ?? where.ids) mapped.include = toArray(where.include ?? where.ids); + if (where.offset !== undefined) mapped.offset = Number(where.offset); + if (where.parent ?? where.parentId) mapped.parent = toArray(where.parent ?? where.parentId); + if (where.parent_exclude ?? where.parentExclude) mapped.parent_exclude = toArray(where.parent_exclude ?? where.parentExclude); + if (where.slug) mapped.slug = String(where.slug); + if (!mapped.status && (where.status || where.include_status || where.exclude_status || where.includeStatus || where.excludeStatus)) { + if (where.include_status ?? where.includeStatus) mapped.include_status = String(where.include_status ?? where.includeStatus); + if (where.exclude_status ?? where.excludeStatus) mapped.exclude_status = String(where.exclude_status ?? where.excludeStatus); + if (where.status) mapped.status = String(where.status); + } + if (where.type) mapped.type = String(where.type); + if (where.include_types ?? where.includeTypes) mapped.include_types = String(where.include_types ?? where.includeTypes); + if (where.exclude_types ?? where.excludeTypes) mapped.exclude_types = String(where.exclude_types ?? where.excludeTypes); + if (where.sku) mapped.sku = String(where.sku); + if (where.featured ?? where.isFeatured) mapped.featured = Boolean(where.featured ?? where.isFeatured); + if (where.category ?? where.categoryId) mapped.category = String(where.category ?? where.categoryId); + if (where.tag ?? where.tagId) mapped.tag = String(where.tag ?? where.tagId); + if (where.shipping_class ?? where.shippingClass) mapped.shipping_class = String(where.shipping_class ?? where.shippingClass); + if (where.attribute ?? where.attributeName) mapped.attribute = String(where.attribute ?? where.attributeName); + if (where.attribute_term ?? where.attributeTermId ?? where.attributeTerm) mapped.attribute_term = String(where.attribute_term ?? where.attributeTermId ?? where.attributeTerm); + if (where.tax_class ?? where.taxClass) mapped.tax_class = String(where.tax_class ?? where.taxClass); + if (where.on_sale ?? where.onSale) mapped.on_sale = Boolean(where.on_sale ?? where.onSale); + if (where.min_price ?? where.minPrice) mapped.min_price = String(where.min_price ?? where.minPrice); + if (where.max_price ?? where.maxPrice) mapped.max_price = String(where.max_price ?? where.maxPrice); + if (where.stock_status ?? where.stockStatus) mapped.stock_status = String(where.stock_status ?? where.stockStatus); + if (where.virtual !== undefined) mapped.virtual = Boolean(where.virtual); + if (where.downloadable !== undefined) mapped.downloadable = Boolean(where.downloadable); + + if (params.ids) { + const idsArr = String(params.ids).split(',').map(v => v.trim()).filter(Boolean); + mapped.include = idsArr; + } + return mapped; + } + + private mapOrderSearchParams(params: UnifiedSearchParamsDTO): Partial { + // 计算分页参数 + const page = Number(params.page ?? 1); + const per_page = Number( params.per_page ?? 20); + // 解析排序参数 支持从 order 对象推导 + const where = params.where && typeof params.where === 'object' ? params.where : {}; + let orderby: string | undefined = params.orderby; + let orderDir: 'asc' | 'desc' | undefined = params.orderDir as any; + if (!orderby && params.order && typeof params.order === 'object') { + const entries = Object.entries(params.order as Record); + if (entries.length > 0) { + const [field, dir] = entries[0]; + let mappedField = field; + if (['created_at', 'date_created', 'date'].includes(field)) mappedField = 'date'; + else if (['modified', 'date_modified'].includes(field)) mappedField = 'modified'; + else if (['id', 'ID'].includes(field)) mappedField = 'id'; + else if (['name', 'title'].includes(field)) mappedField = 'title'; + else if (['slug'].includes(field)) mappedField = 'slug'; + else if (['include'].includes(field)) mappedField = 'include'; + orderby = mappedField; + orderDir = String(dir).toLowerCase() === 'asc' ? 'asc' : 'desc'; + } + } else if (!orderDir && typeof params.order === 'string') { + orderDir = String(params.order).toLowerCase() === 'asc' ? 'asc' : 'desc'; + } + + const mapped: any = { + ...(params.search ? { search: params.search } : {}), + ...(orderby ? { orderby } : {}), + ...(orderDir ? { order: orderDir } : {}), + page, + per_page, + }; + + const toArray = (value: any): any[] => { + if (Array.isArray(value)) return value; + if (value === undefined || value === null) return []; + return String(value).split(',').map(v => v.trim()).filter(Boolean); + }; + + const toNumber = (value: any): number | undefined => { + if (value === undefined || value === null || value === '') return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; + }; + + // 时间过滤参数 + if (where.after ?? where.date_created_after ?? where.created_after) mapped.after = String(where.after ?? where.date_created_after ?? where.created_after); + if (where.before ?? where.date_created_before ?? where.created_before) mapped.before = String(where.before ?? where.date_created_before ?? where.created_before); + if (where.modified_after ?? where.date_modified_after) mapped.modified_after = String(where.modified_after ?? where.date_modified_after); + if (where.modified_before ?? where.date_modified_before) mapped.modified_before = String(where.modified_before ?? where.date_modified_before); + if (where.dates_are_gmt ?? where.datesAreGmt) mapped.dates_are_gmt = Boolean(where.dates_are_gmt ?? where.datesAreGmt); + + // 集合过滤参数 + if (where.exclude) mapped.exclude = toArray(where.exclude); + if (where.include) mapped.include = toArray(where.include); + if (params.ids) mapped.include = String(params.ids).split(',').map(v => v.trim()).filter(Boolean); + if (toNumber(where.offset) !== undefined) mapped.offset = Number(where.offset); + if (where.parent ?? where.parentId) mapped.parent = toArray(where.parent ?? where.parentId); + if (where.parent_exclude ?? where.parentExclude) mapped.parent_exclude = toArray(where.parent_exclude ?? where.parentExclude); + + // 状态过滤 参数支持数组或逗号分隔字符串 + const statusSource = params.status ?? where.status; + if (statusSource !== undefined) { + mapped.status = Array.isArray(statusSource) + ? statusSource.map(s => String(s)) + : String(statusSource).split(',').map(s => s.trim()).filter(Boolean); + } + + // 客户与产品过滤 + const customerVal = params.customer_id ?? where.customer ?? where.customer_id; + const productVal = where.product ?? where.product_id; + const dpVal = where.dp; + if (toNumber(customerVal) !== undefined) mapped.customer = Number(customerVal); + if (toNumber(productVal) !== undefined) mapped.product = Number(productVal); + if (toNumber(dpVal) !== undefined) mapped.dp = Number(dpVal); + + // 创建来源过滤 支持逗号分隔 + const createdViaVal = where.created_via; + if (createdViaVal !== undefined) mapped.created_via = Array.isArray(createdViaVal) + ? createdViaVal.join(',') + : String(createdViaVal); + + return mapped; + } + + private mapCustomerSearchParams(params: UnifiedSearchParamsDTO): Record { + const page = Number(params.page ?? 1); + const per_page = Number(params.per_page ?? 20); + const where = params.where && typeof params.where === 'object' ? params.where : {}; + let orderby: string | undefined = params.orderby; + let orderDir: 'asc' | 'desc' | undefined = params.orderDir as any; + if (!orderby && params.order && typeof params.order === 'object') { + const entries = Object.entries(params.order as Record); + if (entries.length > 0) { + const [field, dir] = entries[0]; + let mappedField = field; + if (['id', 'ID'].includes(field)) mappedField = 'id'; + else if (['include'].includes(field)) mappedField = 'include'; + else if (['name', 'username'].includes(field)) mappedField = 'name'; + else if (['registered_date', 'date_created', 'registered', 'registeredDate'].includes(field)) mappedField = 'registered_date'; + orderby = mappedField; + orderDir = String(dir).toLowerCase() === 'asc' ? 'asc' : 'desc'; + } + } else if (!orderDir && typeof params.order === 'string') { + orderDir = String(params.order).toLowerCase() === 'asc' ? 'asc' : 'desc'; + } + + const mapped: any = { + ...(params.search ? { search: params.search } : {}), + ...(orderby ? { orderby } : {}), + ...(orderDir ? { order: orderDir } : {}), + page, + per_page, + }; + + const toArray = (value: any): any[] => { + if (Array.isArray(value)) return value; + if (value === undefined || value === null) return []; + return String(value).split(',').map(v => v.trim()).filter(Boolean); + }; + + const toNumber = (value: any): number | undefined => { + if (value === undefined || value === null || value === '') return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; + }; + + if (where.exclude) mapped.exclude = toArray(where.exclude); + if (where.include) mapped.include = toArray(where.include); + if (params.ids) mapped.include = String(params.ids).split(',').map(v => v.trim()).filter(Boolean); + if (toNumber(where.offset) !== undefined) mapped.offset = Number(where.offset); + + if (where.email) mapped.email = String(where.email); + const roleSource = where.role ?? params.status; + if (roleSource !== undefined) mapped.role = String(roleSource); + + return mapped; + } + + private mapProduct(item: WooProduct): UnifiedProductDTO { + // 将 WooCommerce 产品数据映射为统一产品DTO + // 保留常用字段与时间信息以便前端统一展示 + // https://woocommerce.github.io/woocommerce-rest-api-docs/?javascript#product-properties + return { + id: item.id, + date_created: item.date_created, + date_modified: item.date_modified, + type: item.type, // simple grouped external variable + status: item.status, // draft pending private publish + sku: item.sku, + name: item.name, + //价格 + regular_price: item.regular_price, + sale_price: item.sale_price, + price: item.price, + stock_status: item.stock_status, + stock_quantity: item.stock_quantity, + images: (item.images || []).map((img: any) => ({ + id: img.id, + src: img.src, + name: img.name, + alt: img.alt, + })), + categories: (item.categories || []).map((c: any) => ({ + id: c.id, + name: c.name, + })), + tags: (item.tags || []).map((t: any) => ({ + id: t.id, + name: t.name, + })), + attributes: (item.attributes || []).map(attr => ({ + id: attr.id, + name: attr.name || '', + position: attr.position, + visible: attr.visible, + variation: attr.variation, + options: attr.options || [] + })), + variations: item.variations as any, + permalink: item.permalink, + raw: item, + }; + } + private buildFullAddress(addr: any): string { + if (!addr) return ''; + const name = addr.fullname || `${addr.first_name || ''} ${addr.last_name || ''}`.trim(); + return [ + name, + addr.company, + addr.address_1, + addr.address_2, + addr.city, + addr.state, + addr.postcode, + addr.country, + addr.phone + ].filter(Boolean).join(', '); + } + private mapOrder(item: WooOrder): UnifiedOrderDTO { + // 将 WooCommerce 订单数据映射为统一订单DTO + // 包含账单地址与收货地址以及创建与更新时间 + return { + id: item.id, + number: item.number, + status: item.status, + currency: item.currency, + total: item.total, + customer_id: item.customer_id, + customer_name: `${item.billing?.first_name || ''} ${ + item.billing?.last_name || '' + }`.trim(), + refunds: item.refunds?.map?.(refund => ({ + id: refund.id, + reason: refund.reason, + total: refund.total, + })), + email: item.billing?.email || '', + line_items: (item.line_items as any[]).map(li => ({ + ...li, + productId: li.product_id, + })), + + billing: item.billing, + shipping: item.shipping, + billing_full_address: this.buildFullAddress(item.billing), + shipping_full_address: this.buildFullAddress(item.shipping), + payment_method: item.payment_method_title, + date_created: item.date_created, + date_modified: item.date_modified, + raw: item, + }; + } + + private mapSubscription(item: WooSubscription): UnifiedSubscriptionDTO { + // 将 WooCommerce 订阅数据映射为统一订阅DTO + // 若缺少创建时间则回退为开始时间 + return { + id: item.id, + status: item.status, + customer_id: item.customer_id, + billing_period: item.billing_period, + billing_interval: item.billing_interval, + date_created: item.date_created ?? item.start_date, + date_modified: item.date_modified, + start_date: item.start_date, + next_payment_date: item.next_payment_date, + line_items: item.line_items, + raw: item, + }; + } + + private mapMedia(item: WpMedia): UnifiedMediaDTO { + // 将 WordPress 媒体数据映射为统一媒体DTO + // 兼容不同字段命名的时间信息 + return { + id: item.id, + title: + typeof item.title === 'string' + ? item.title + : item.title?.rendered || '', + media_type: item.media_type, + mime_type: item.mime_type, + source_url: item.source_url, + date_created: item.date_created ?? item.date, + date_modified: item.date_modified ?? item.modified, + }; + } + + async getProducts( + params: UnifiedSearchParamsDTO + ): Promise> { + // 获取产品列表并使用统一分页结构返回 + const requestParams = this.mapProductSearchParams(params); + const { items, total, totalPages, page, per_page } = + await this.wpService.fetchResourcePaged( + this.site, + 'products', + requestParams + ); + return { + items: items.map(this.mapProduct), + total, + totalPages, + page, + per_page, + + }; + } + + async getProduct(id: string | number): Promise { + // 获取单个产品详情并映射为统一产品DTO + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.get(`products/${id}`); + return this.mapProduct(res.data); + } + + async createProduct(data: Partial): Promise { + // 创建产品并返回统一产品DTO + const res = await this.wpService.createProduct(this.site, data); + return this.mapProduct(res); + } + + async updateProduct(id: string | number, data: Partial): Promise { + // 更新产品并返回统一产品DTO + const res = await this.wpService.updateProduct(this.site, String(id), data as any); + return res + } + + async updateVariation(productId: string | number, variationId: string | number, data: any): Promise { + // 更新变体信息并返回结果 + const res = await this.wpService.updateVariation(this.site, String(productId), String(variationId), data); + return res; + } + + async getOrderNotes(orderId: string | number): Promise { + // 获取订单备注列表 + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.get(`orders/${orderId}/notes`); + return res.data; + } + + async createOrderNote(orderId: string | number, data: any): Promise { + // 创建订单备注 + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.post(`orders/${orderId}/notes`, data); + return res.data; + } + + async deleteProduct(id: string | number): Promise { + // 删除产品 + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + try { + await api.delete(`products/${id}`, { force: true }); + return true; + } catch (e) { + return false; + } + } + + async batchProcessProducts( + data: { create?: any[]; update?: any[]; delete?: Array } + ): Promise { + // 批量处理产品增删改 + return await this.wpService.batchProcessProducts(this.site, data); + } + + async getOrders( + params: UnifiedSearchParamsDTO + ): Promise> { + const requestParams = this.mapOrderSearchParams(params); + const { items, total, totalPages, page, per_page } = + await this.wpService.fetchResourcePaged(this.site, 'orders', requestParams); + return { + items: items.map(this.mapOrder), + total, + totalPages, + page, + per_page, + + }; + } + + async getOrder(id: string | number): Promise { + // 获取单个订单详情 + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.get(`orders/${id}`); + return this.mapOrder(res.data); + } + + async createOrder(data: Partial): Promise { + // 创建订单并返回统一订单DTO + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.post('orders', data); + return this.mapOrder(res.data); + } + + async updateOrder(id: string | number, data: Partial): Promise { + // 更新订单并返回布尔结果 + return await this.wpService.updateOrder(this.site, String(id), data as any); + } + + async deleteOrder(id: string | number): Promise { + // 删除订单 + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + await api.delete(`orders/${id}`, { force: true }); + return true; + } + + async shipOrder(orderId: string | number, data: { + tracking_number?: string; + shipping_provider?: string; + shipping_method?: string; + items?: Array<{ + order_item_id: number; + quantity: number; + }>; + }): Promise { + throw new Error('暂无实现') + // 订单发货 + // const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + + // try { + // // 更新订单状态为已完成 + // await api.put(`orders/${orderId}`, { status: 'completed' }); + + // // 如果提供了物流信息,添加到订单备注 + // if (data.tracking_number || data.shipping_provider) { + // const note = `订单已发货${data.tracking_number ? `,物流单号:${data.tracking_number}` : ''}${data.shipping_provider ? `,物流公司:${data.shipping_provider}` : ''}`; + // await api.post(`orders/${orderId}/notes`, { note, customer_note: true }); + // } + + // return { + // success: true, + // order_id: orderId, + // shipment_id: `shipment_${orderId}_${Date.now()}`, + // tracking_number: data.tracking_number, + // shipping_provider: data.shipping_provider, + // shipped_at: new Date().toISOString() + // }; + // } catch (error) { + // throw new Error(`发货失败: ${error.message}`); + // } + } + + async cancelShipOrder(orderId: string | number, data: { + reason?: string; + shipment_id?: string; + }): Promise { + throw new Error('暂未实现') + // 取消订单发货 + // const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + + // try { + // // 将订单状态改回处理中 + // await api.put(`orders/${orderId}`, { status: 'processing' }); + + // // 添加取消发货的备注 + // const note = `订单发货已取消${data.reason ? `,原因:${data.reason}` : ''}`; + // await api.post(`orders/${orderId}/notes`, { note, customer_note: true }); + + // return { + // success: true, + // order_id: orderId, + // shipment_id: data.shipment_id, + // reason: data.reason, + // cancelled_at: new Date().toISOString() + // }; + // } catch (error) { + // throw new Error(`取消发货失败: ${error.message}`); + // } + } + + async getSubscriptions( + params: UnifiedSearchParamsDTO + ): Promise> { + // 获取订阅列表并映射为统一订阅DTO集合 + const { items, total, totalPages, page, per_page } = + await this.wpService.fetchResourcePaged( + this.site, + 'subscriptions', + params + ); + return { + items: items.map(this.mapSubscription), + total, + totalPages, + page, + per_page, + + }; + } + + async getMedia( + params: UnifiedSearchParamsDTO + ): Promise> { + // 获取媒体列表并映射为统一媒体DTO集合 + const { items, total, totalPages, page, per_page } = await this.wpService.fetchMediaPaged( + this.site, + params + ); + return { + items: items.map(this.mapMedia.bind(this)), + total, + totalPages, + page, + per_page, + }; + } + + private mapReview(item: any): UnifiedReviewDTO & {raw: any} { + // 将 WooCommerce 评论数据映射为统一评论DTO + return { + id: item.id, + product_id: item.product_id, + author: item.reviewer, + email: item.reviewer_email, + content: item.review, + rating: item.rating, + status: item.status, + date_created: item.date_created, + raw: item + }; + } + + async getReviews( + params: UnifiedSearchParamsDTO + ): Promise { + // 获取评论列表并使用统一分页结构返回 + const requestParams = this.mapProductSearchParams(params); + const { items, total, totalPages, page, per_page } = + await this.wpService.fetchResourcePaged( + this.site, + 'products/reviews', + requestParams + ); + return { + items: items.map(this.mapReview.bind(this)), + total, + totalPages, + page, + per_page, + }; + } + + async createReview(data: any): Promise { + const res = await this.wpService.createReview(this.site, data); + return this.mapReview(res); + } + + async updateReview(id: number, data: any): Promise { + const res = await this.wpService.updateReview(this.site, id, data); + return this.mapReview(res); + } + + async deleteReview(id: number): Promise { + return await this.wpService.deleteReview(this.site, id); + } + + async deleteMedia(id: string | number): Promise { + // 删除媒体资源 + await this.wpService.deleteMedia(Number(this.site.id), Number(id), true); + return true; + } + + async updateMedia(id: string | number, data: any): Promise { + // 更新媒体信息 + return await this.wpService.updateMedia(Number(this.site.id), Number(id), data); + } + + async convertMediaToWebp(ids: Array): Promise<{ converted: any[]; failed: any[] }> { + // 函数说明 调用服务层将站点的指定媒体批量转换为 webp 并上传 + const result = await this.wpService.convertMediaToWebp(Number(this.site.id), ids); + return result as any; + } + + private mapCustomer(item: WooCustomer): UnifiedCustomerDTO { + // 将 WooCommerce 客户数据映射为统一客户DTO + // 包含基础信息地址信息与时间信息 + return { + id: item.id, + avatar: item.avatar_url, + email: item.email, + orders: Number(item.orders?? 0), + total_spend: Number(item.total_spent ?? 0), + first_name: item.first_name, + last_name: item.last_name, + username: item.username, + phone: item.billing?.phone || item.shipping?.phone, + billing: item.billing, + shipping: item.shipping, + date_created: item.date_created, + date_modified: item.date_modified, + raw: item, + }; + } + + async getCustomers(params: UnifiedSearchParamsDTO): Promise> { + const requestParams = this.mapCustomerSearchParams(params); + const { items, total, totalPages, page, per_page } = await this.wpService.fetchResourcePaged( + this.site, + 'customers', + requestParams + ); + return { + items: items.map((i: any) => this.mapCustomer(i)), + total, + totalPages, + page, + per_page, + + }; + } + + async getCustomer(id: string | number): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.get(`customers/${id}`); + return this.mapCustomer(res.data); + } + + async createCustomer(data: Partial): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.post('customers', data); + return this.mapCustomer(res.data); + } + + async updateCustomer(id: string | number, data: Partial): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + const res = await api.put(`customers/${id}`, data); + return this.mapCustomer(res.data); + } + + async deleteCustomer(id: string | number): Promise { + const api = (this.wpService as any).createApi(this.site, 'wc/v3'); + await api.delete(`customers/${id}`, { force: true }); + return true; + } +} diff --git a/src/config/config.default.ts b/src/config/config.default.ts index fd99d08..3314dfe 100644 --- a/src/config/config.default.ts +++ b/src/config/config.default.ts @@ -1,6 +1,6 @@ import { MidwayConfig } from '@midwayjs/core'; +import { join } from 'path'; import { Product } from '../entity/product.entity'; -import { Category } from '../entity/category.entity'; import { WpProduct } from '../entity/wp_product.entity'; import { Variation } from '../entity/variation.entity'; import { User } from '../entity/user.entity'; @@ -11,12 +11,12 @@ import { StockPoint } from '../entity/stock_point.entity'; import { StockRecord } from '../entity/stock_record.entity'; import { Order } from '../entity/order.entity'; import { OrderItem } from '../entity/order_item.entity'; -import { OrderCoupon } from '../entity/order_copon.entity'; +import { OrderCoupon } from '../entity/order_coupon.entity'; import { OrderFee } from '../entity/order_fee.entity'; import { OrderRefund } from '../entity/order_refund.entity'; -import { OrderRefundItem } from '../entity/order_retund_item.entity'; +import { OrderRefundItem } from '../entity/order_refund_item.entity'; import { OrderSale } from '../entity/order_sale.entity'; -import { OrderSaleOriginal } from '../entity/order_item_original.entity'; +import { OrderItemOriginal } from '../entity/order_item_original.entity'; import { OrderShipping } from '../entity/order_shipping.entity'; import { Service } from '../entity/service.entity'; import { ShippingAddress } from '../entity/shipping_address.entity'; @@ -26,14 +26,23 @@ import { Shipment } from '../entity/shipment.entity'; import { ShipmentItem } from '../entity/shipment_item.entity'; import { Transfer } from '../entity/transfer.entity'; import { TransferItem } from '../entity/transfer_item.entity'; -import { Strength } from '../entity/strength.entity'; -import { Flavors } from '../entity/flavors.entity'; import { CustomerTag } from '../entity/customer_tag.entity'; import { Customer } from '../entity/customer.entity'; import { DeviceWhitelist } from '../entity/device_whitelist'; import { AuthCode } from '../entity/auth_code'; import { Subscription } from '../entity/subscription.entity'; import { Site } from '../entity/site.entity'; +import { Dict } from '../entity/dict.entity'; +import { DictItem } from '../entity/dict_item.entity'; +import { Template } from '../entity/template.entity'; +import { Area } from '../entity/area.entity'; +import { ProductStockComponent } from '../entity/product_stock_component.entity'; +import { ProductSiteSku } from '../entity/product_site_sku.entity'; +import { CategoryAttribute } from '../entity/category_attribute.entity'; +import { Category } from '../entity/category.entity'; +import DictSeeder from '../db/seeds/dict.seeder'; +import CategorySeeder from '../db/seeds/category.seeder'; +import CategoryAttributeSeeder from '../db/seeds/category_attribute.seeder'; export default { // use for cookie sign key, should change to your own and keep security @@ -42,9 +51,8 @@ export default { default: { entities: [ Product, - Category, - Strength, - Flavors, + ProductStockComponent, + ProductSiteSku, WpProduct, Variation, User, @@ -60,7 +68,7 @@ export default { OrderRefund, OrderRefundItem, OrderSale, - OrderSaleOriginal, + OrderItemOriginal, OrderShipment, ShipmentItem, Shipment, @@ -76,15 +84,22 @@ export default { AuthCode, Subscription, Site, + Dict, + DictItem, + Template, + Area, + CategoryAttribute, + Category, ], synchronize: true, logging: false, + seeders: [DictSeeder, CategorySeeder, CategoryAttributeSeeder], }, dataSource: { default: { type: 'mysql', host: 'localhost', - port: 3306, + port: 10014, username: 'root', password: 'root', database: 'inventory', @@ -95,7 +110,7 @@ export default { // origin: '*', // 允许所有来源跨域请求 // allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // 允许的 HTTP 方法 // allowHeaders: ['Content-Type', 'Authorization'], // 允许的自定义请求头 - // credentials: true, // 允许携带凭据(cookies等) + // credentials: true, // 允许携带凭据(cookies等) // }, // jwt: { // secret: 'YOONE2024!@abc', @@ -107,7 +122,7 @@ export default { // wpApiUrl: 'http://localhost:10004', // consumerKey: 'ck_dc9e151e9048c8ed3e27f35ac79d2bf7d6840652', // consumerSecret: 'cs_d05d625d7b0ac05c6d765671d8417f41d9477e38', - // siteName: 'Local', + // name: 'Local', // email: 'tom@yoonevape.com', // emailPswd: '', // }, @@ -128,5 +143,16 @@ export default { user: 'info@canpouches.com', pass: 'WWqQ4aZq4Jrm9uwz', }, -} + }, + upload: { + // mode: 'file', // 默认为file,即上传到服务器临时目录,可以配置为 stream + mode: 'file', + fileSize: '10mb', // 最大支持的文件大小,默认为 10mb + whitelist: ['.csv'], // 支持的文件后缀 + tmpdir: join(__dirname, '../../tmp_uploads'), + cleanTimeout: 5 * 60 * 1000, + }, + koa: { + queryParseMode: 'extended', + }, } as MidwayConfig; diff --git a/src/configuration.ts b/src/configuration.ts index e413b12..3bd7813 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -9,13 +9,15 @@ import * as validate from '@midwayjs/validate'; import * as info from '@midwayjs/info'; import * as orm from '@midwayjs/typeorm'; import { join } from 'path'; -// import { DefaultErrorFilter } from './filter/default.filter'; -// import { NotFoundFilter } from './filter/notfound.filter'; +import { DefaultErrorFilter } from './filter/default.filter'; +import { NotFoundFilter } from './filter/notfound.filter'; import { ReportMiddleware } from './middleware/report.middleware'; +import { QueryNormalizeMiddleware } from './middleware/query-normalize.middleware'; import * as swagger from '@midwayjs/swagger'; import * as crossDomain from '@midwayjs/cross-domain'; import * as cron from '@midwayjs/cron'; import * as jwt from '@midwayjs/jwt'; +import * as upload from '@midwayjs/upload'; import { USER_KEY } from './decorator/user.decorator'; import { SiteService } from './service/site.service'; import { AuthMiddleware } from './middleware/auth.middleware'; @@ -33,6 +35,7 @@ import { AuthMiddleware } from './middleware/auth.middleware'; crossDomain, cron, jwt, + upload, ], importConfigs: [join(__dirname, './config')], }) @@ -51,9 +54,9 @@ export class MainConfiguration { async onReady() { // add middleware - this.app.useMiddleware([ReportMiddleware, AuthMiddleware]); + this.app.useMiddleware([QueryNormalizeMiddleware, ReportMiddleware, AuthMiddleware]); // add filter - // this.app.useFilter([NotFoundFilter, DefaultErrorFilter]); + this.app.useFilter([NotFoundFilter, DefaultErrorFilter]); this.decoratorService.registerParameterHandler( USER_KEY, diff --git a/src/controller/area.controller.ts b/src/controller/area.controller.ts new file mode 100644 index 0000000..4c28193 --- /dev/null +++ b/src/controller/area.controller.ts @@ -0,0 +1,118 @@ + +import { Body, Context, Controller, Del, Get, Inject, Param, Post, Put, Query } from '@midwayjs/core'; +import { + ApiBearerAuth, + ApiBody, + ApiExtension, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@midwayjs/swagger'; +import { AreaService } from '../service/area.service'; +import { CreateAreaDTO, QueryAreaDTO, UpdateAreaDTO } from '../dto/area.dto'; +import { errorResponse, successResponse } from '../utils/response.util'; +import { Area } from '../entity/area.entity'; +import * as countries from 'i18n-iso-countries'; + +@ApiBearerAuth() +@ApiTags('Area') +@Controller('/area') +export class AreaController { + @Inject() + ctx: Context; + + @Inject() + areaService: AreaService; + + @ApiOperation({ summary: '获取国家列表' }) + @ApiOkResponse({ description: '国家列表' }) + @Get('/countries') + async getCountries() { + try { + // 注册中文语言包 + countries.registerLocale(require('i18n-iso-countries/langs/zh.json')); + // 获取所有国家的中文名称 + const countryNames = countries.getNames('zh', { select: 'official' }); + // 格式化为 { code, name } 的数组 + const countryList = Object.keys(countryNames).map(code => ({ + code, + name: countryNames[code], + })); + return successResponse(countryList, '查询成功'); + } catch (error) { + console.log(error); + return errorResponse(error?.message || error); + } + } + + @ApiOperation({ summary: '创建区域' }) + @ApiBody({ type: CreateAreaDTO }) + @ApiOkResponse({ type: Area, description: '成功创建的区域' }) + @Post('/') + async createArea(@Body() area: CreateAreaDTO) { + try { + const newArea = await this.areaService.createArea(area); + return successResponse(newArea, '创建成功'); + } catch (error) { + console.log(error); + return errorResponse(error?.message || error); + } + } + + @ApiOperation({ summary: '更新区域' }) + @ApiBody({ type: UpdateAreaDTO }) + @ApiOkResponse({ type: Area, description: '成功更新的区域' }) + @Put('/:id') + async updateArea(@Param('id') id: number, @Body() area: UpdateAreaDTO) { + try { + const updatedArea = await this.areaService.updateArea(id, area); + return successResponse(updatedArea, '更新成功'); + } catch (error) { + console.log(error); + return errorResponse(error?.message || error); + } + } + + @ApiOperation({ summary: '删除区域' }) + @ApiOkResponse({ description: '删除成功' }) + @Del('/:id') + async deleteArea(@Param('id') id: number) { + try { + await this.areaService.deleteArea(id); + return successResponse(null, '删除成功'); + } catch (error) { + console.log(error); + return errorResponse(error?.message || error); + } + } + + @ApiOperation({ summary: '获取区域列表(分页)' }) + @ApiOkResponse({ type: [Area], description: '区域列表' }) + @ApiExtension('x-pagination', { currentPage: 1, pageSize: 10, total: 100 }) + @Get('/') + async getAreaList(@Query() query: QueryAreaDTO) { + try { + const { list, total } = await this.areaService.getAreaList(query); + return successResponse({ list, total }, '查询成功'); + } catch (error) { + console.log(error); + return errorResponse(error?.message || error); + } + } + + @ApiOperation({ summary: '根据ID获取区域详情' }) + @ApiOkResponse({ type: Area, description: '区域详情' }) + @Get('/:id') + async getAreaById(@Param('id') id: number) { + try { + const area = await this.areaService.getAreaById(id); + if (!area) { + return errorResponse('区域不存在'); + } + return successResponse(area, '查询成功'); + } catch (error) { + console.log(error); + return errorResponse(error?.message || error); + } + } +} diff --git a/src/controller/category.controller.ts b/src/controller/category.controller.ts new file mode 100644 index 0000000..e8b625f --- /dev/null +++ b/src/controller/category.controller.ts @@ -0,0 +1,98 @@ +import { Controller, Get, Post, Put, Del, Body, Query, Inject, Param } from '@midwayjs/core'; +import { CategoryService } from '../service/category.service'; +import { successResponse, errorResponse } from '../utils/response.util'; +import { ApiOkResponse } from '@midwayjs/swagger'; + +@Controller('/category') +export class CategoryController { + @Inject() + categoryService: CategoryService; + + @ApiOkResponse() + @Get('/all') + async getAll() { + try { + const data = await this.categoryService.getAll(); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Get('/') + async getList(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { + try { + const data = await this.categoryService.getList({ current, pageSize }, name); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Post('/') + async create(@Body() body: any) { + try { + const data = await this.categoryService.create(body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Put('/:id') + async update(@Param('id') id: number, @Body() body: any) { + try { + const data = await this.categoryService.update(id, body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Del('/:id') + async delete(@Param('id') id: number) { + try { + await this.categoryService.delete(id); + return successResponse(null); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Get('/attribute/:categoryId') + async getCategoryAttributes(@Param('categoryId') categoryId: number) { + try { + const data = await this.categoryService.getCategoryAttributes(categoryId); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Post('/attribute') + async createCategoryAttribute(@Body() body: { categoryId: number, attributeDictIds: number[] }) { + try { + const data = await this.categoryService.createCategoryAttribute(body.categoryId, body.attributeDictIds); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Del('/attribute/:id') + async deleteCategoryAttribute(@Param('id') id: number) { + try { + await this.categoryService.deleteCategoryAttribute(id); + return successResponse(null); + } catch (error) { + return errorResponse(error?.message || error); + } + } +} diff --git a/src/controller/customer.controller.ts b/src/controller/customer.controller.ts index 151d179..9225aa5 100644 --- a/src/controller/customer.controller.ts +++ b/src/controller/customer.controller.ts @@ -1,82 +1,66 @@ -import { - Body, - Context, - Controller, - Del, - Get, - Inject, - Post, - Put, - Query, -} from '@midwayjs/core'; +import { Controller, Get, Post, Inject, Query, Body } from '@midwayjs/core'; +import { successResponse, errorResponse } from '../utils/response.util'; import { CustomerService } from '../service/customer.service'; -import { errorResponse, successResponse } from '../utils/response.util'; +import { QueryCustomerListDTO, CustomerTagDTO } from '../dto/customer.dto'; import { ApiOkResponse } from '@midwayjs/swagger'; -import { BooleanRes } from '../dto/reponse.dto'; -import { CustomerTagDTO, QueryCustomerListDTO } from '../dto/customer.dto'; @Controller('/customer') export class CustomerController { - @Inject() - ctx: Context; - @Inject() customerService: CustomerService; - @ApiOkResponse() - @Get('/list') - async getCustomerList(@Query() param: QueryCustomerListDTO) { + @ApiOkResponse({ type: Object }) + @Get('/getcustomerlist') + async getCustomerList(@Query() query: QueryCustomerListDTO) { try { - const data = await this.customerService.getCustomerList(param); - return successResponse(data); + const result = await this.customerService.getCustomerList(query as any); + return successResponse(result); } catch (error) { - console.log(error) - return errorResponse(error?.message || error); + return errorResponse(error.message); } } - @ApiOkResponse({ type: BooleanRes }) - @Post('/tag/add') - async addTag(@Body() dto: CustomerTagDTO) { + @ApiOkResponse({ type: Object }) + @Post('/addtag') + async addTag(@Body() body: CustomerTagDTO) { try { - await this.customerService.addTag(dto.email, dto.tag); - return successResponse(true); + const result = await this.customerService.addTag(body.email, body.tag); + return successResponse(result); } catch (error) { - return errorResponse(error?.message || error); + return errorResponse(error.message); } } - @ApiOkResponse({ type: BooleanRes }) - @Del('/tag/del') - async delTag(@Body() dto: CustomerTagDTO) { + @ApiOkResponse({ type: Object }) + @Post('/deltag') + async delTag(@Body() body: CustomerTagDTO) { try { - await this.customerService.delTag(dto.email, dto.tag); - return successResponse(true); + const result = await this.customerService.delTag(body.email, body.tag); + return successResponse(result); } catch (error) { - return errorResponse(error?.message || error); + return errorResponse(error.message); } } - @ApiOkResponse() - @Get('/tags') + @ApiOkResponse({ type: Object }) + @Get('/gettags') async getTags() { try { - const data = await this.customerService.getTags(); - return successResponse(data); + const result = await this.customerService.getTags(); + return successResponse(result); } catch (error) { - return errorResponse(error?.message || error); + return errorResponse(error.message); } } - - @ApiOkResponse({ type: BooleanRes }) - @Put('/rate') - async setRate(@Body() params: { id: number; rate: number }) { + @ApiOkResponse({ type: Object }) + @Post('/setrate') + async setRate(@Body() body: { id: number; rate: number }) { try { - await this.customerService.setRate(params); - return successResponse(true); + const result = await this.customerService.setRate({ id: body.id, rate: body.rate }); + return successResponse(result); } catch (error) { - return errorResponse(error?.message || error); + return errorResponse(error.message); } } } diff --git a/src/controller/dict.controller.ts b/src/controller/dict.controller.ts new file mode 100644 index 0000000..8413ca2 --- /dev/null +++ b/src/controller/dict.controller.ts @@ -0,0 +1,221 @@ + +import { Inject, Controller, Get, Post, Put, Del, Query, Body, Param, Files, ContentType } from '@midwayjs/core'; +import { DictService } from '../service/dict.service'; +import { CreateDictDTO, UpdateDictDTO, CreateDictItemDTO, UpdateDictItemDTO } from '../dto/dict.dto'; +import { Validate } from '@midwayjs/validate'; +import { Context } from '@midwayjs/koa'; +import { successResponse, errorResponse } from '../utils/response.util'; + +/** + * 字典管理 + * @decorator Controller + */ +@Controller('/dict') +export class DictController { + @Inject() + dictService: DictService; + + @Inject() + ctx: Context; + + /** + * 批量导入字典 + * @param files 上传的文件 + */ + @Post('/import') + @Validate() + async importDicts(@Files() files: any) { + // 从上传的文件列表中获取第一个文件 + const file = files[0]; + // 调用服务层方法处理XLSX文件 + const result = await this.dictService.importDictsFromXLSX(file.data); + // 返回导入结果 + return result; + } + + /** + * 下载字典XLSX模板 + */ + @Get('/template') + @ContentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + async downloadDictTemplate() { + // 设置下载文件的名称 + this.ctx.set('Content-Disposition', 'attachment; filename=dict-template.xlsx'); + // 返回XLSX模板内容 + return this.dictService.getDictXLSXTemplate(); + } + + /** + * 获取单个字典及其所有字典项 + * @param id 字典ID + */ + @Get('/:id') + async getDict(@Param('id') id: number) { + // 调用服务层方法,并关联查询字典项 + return this.dictService.getDict({ id }, ['items']); + } + + /** + * 获取字典列表 + * @param title 字典标题 (模糊查询) + * @param name 字典名称 (模糊查询) + */ + @Get('/list') + async getDicts(@Query('title') title?: string, @Query('name') name?: string) { + // 调用服务层方法 + return this.dictService.getDicts({ title, name }); + } + + /** + * 创建新字典 + * @param createDictDTO 字典数据 + */ + @Post('/') + @Validate() + async createDict(@Body() createDictDTO: CreateDictDTO) { + try { + // 调用服务层方法 + const result = await this.dictService.createDict(createDictDTO); + return successResponse(result, '字典创建成功'); + } catch (error) { + return errorResponse(error?.message || '字典创建失败', error?.code || 500); + } + } + + /** + * 更新字典 + * @param id 字典ID + * @param updateDictDTO 待更新的字典数据 + */ + @Put('/:id') + @Validate() + async updateDict(@Param('id') id: number, @Body() updateDictDTO: UpdateDictDTO) { + try { + // 调用服务层方法 + const result = await this.dictService.updateDict(id, updateDictDTO); + return successResponse(result, '字典更新成功'); + } catch (error) { + return errorResponse(error?.message || '字典更新失败', error?.code || 500); + } + } + + /** + * 删除字典 + * @param id 字典ID + */ + @Del('/:id') + async deleteDict(@Param('id') id: number) { + try { + // 调用服务层方法 + const result = await this.dictService.deleteDict(id); + return successResponse(result, '字典删除成功'); + } catch (error) { + return errorResponse(error?.message || '字典删除失败', error?.code || 500); + } + } + + /** + * 批量导入字典项 + * @param files 上传的文件 + * @param body 请求体,包含字典ID + */ + @Post('/item/import') + @Validate() + async importDictItems(@Files() files: any, @Body() body: { dictId: number }) { + // 获取第一个文件 + const file = files[0]; + // 调用服务层方法 + const result = await this.dictService.importDictItemsFromXLSX(file.data, body.dictId); + // 返回结果 + return result; + } + + /** + * 下载字典项XLSX模板 + */ + @Get('/item/template') + @ContentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + async downloadDictItemTemplate() { + // 设置下载文件名 + this.ctx.set('Content-Disposition', 'attachment; filename=dict-item-template.xlsx'); + // 返回模板内容 + return this.dictService.getDictItemXLSXTemplate(); + } + + /** + * 获取字典项列表 + * @param dictId 字典ID (可选) + */ + @Get('/items') + async getDictItems( + @Query('dictId') dictId?: number, + @Query('name') name?: string, + @Query('title') title?: string, + ) { + try { + // 调用服务层方法 + const result = await this.dictService.getDictItems({ dictId, name, title }); + return successResponse(result, '获取字典项列表成功'); + } catch (error) { + return errorResponse(error?.message || '获取字典项列表失败', error?.code || 500); + } + } + + /** + * 创建新字典项 + * @param createDictItemDTO 字典项数据 + */ + @Post('/item') + @Validate() + async createDictItem(@Body() createDictItemDTO: CreateDictItemDTO) { + try { + // 调用服务层方法 + const result = await this.dictService.createDictItem(createDictItemDTO); + return successResponse(result, '字典项创建成功'); + } catch (error) { + return errorResponse(error?.message || '字典项创建失败', error?.code || 500); + } + } + + /** + * 更新字典项 + * @param id 字典项ID + * @param updateDictItemDTO 待更新的字典项数据 + */ + @Put('/item/:id') + @Validate() + async updateDictItem(@Param('id') id: number, @Body() updateDictItemDTO: UpdateDictItemDTO) { + try { + // 调用服务层方法 + const result = await this.dictService.updateDictItem(id, updateDictItemDTO); + return successResponse(result, '字典项更新成功'); + } catch (error) { + return errorResponse(error?.message || '字典项更新失败', error?.code || 500); + } + } + + /** + * 删除字典项 + * @param id 字典项ID + */ + @Del('/item/:id') + async deleteDictItem(@Param('id') id: number) { + try { + // 调用服务层方法 + const result = await this.dictService.deleteDictItem(id); + return successResponse(result, '字典项删除成功'); + } catch (error) { + return errorResponse(error?.message || '字典项删除失败', error?.code || 500); + } + } + + /** + * 根据字典名称获取字典项列表 + * @param name 字典名称 + */ + @Get('/items-by-name') + async getDictItemsByDictName(@Query('name') name: string) { + // 调用服务层方法 + return this.dictService.getDictItemsByDictName(name); + } +} diff --git a/src/controller/locale.controller.ts b/src/controller/locale.controller.ts new file mode 100644 index 0000000..3377512 --- /dev/null +++ b/src/controller/locale.controller.ts @@ -0,0 +1,36 @@ + +import { Controller, Get, Inject, Param } from '@midwayjs/core'; +import { DictService } from '../service/dict.service'; + +/** + * 国际化语言包 + */ +@Controller('/locales') +export class LocaleController { + @Inject() + dictService: DictService; + + /** + * 根据语言获取所有翻译项 + * @param lang 语言代码,例如 zh-CN, en-US + * @returns 返回一个包含所有翻译键值对的 JSON 对象 + */ + @Get('/:lang') + async getLocale(@Param('lang') lang: string) { + // 根据语言代码查找对应的字典 + const dict = await this.dictService.getDict({ name: lang }, ['items']); + + // 如果字典不存在,则返回空对象 + if (!dict) { + return {}; + } + + // 将字典项转换为 key-value 对象 + const locale = dict.items.reduce((acc, item) => { + acc[item.name] = item.title; + return acc; + }, {}); + + return locale; + } +} diff --git a/src/controller/media.controller.ts b/src/controller/media.controller.ts new file mode 100644 index 0000000..cafd85f --- /dev/null +++ b/src/controller/media.controller.ts @@ -0,0 +1,79 @@ +import { Controller, Get, Inject, Query, Post, Del, Param, Files, Fields, Body } from '@midwayjs/core'; +import { WPService } from '../service/wp.service'; +import { successResponse, errorResponse } from '../utils/response.util'; + +@Controller('/media') +export class MediaController { + @Inject() + wpService: WPService; + + @Get('/list') + async list( + @Query('siteId') siteId: number, + @Query('page') page: number = 1, + @Query('pageSize') pageSize: number = 20 + ) { + try { + if (!siteId) { + return errorResponse('siteId is required'); + } + const result = await this.wpService.getMedia(siteId, page, pageSize); + return successResponse(result); + } catch (error) { + return errorResponse(error.message); + } + } + + @Post('/upload') + async upload(@Fields() fields, @Files() files) { + try { + const siteId = fields.siteId; + if (!siteId) { + return errorResponse('siteId is required'); + } + if (!files || files.length === 0) { + return errorResponse('file is required'); + } + const file = files[0]; + const result = await this.wpService.createMedia(siteId, file); + return successResponse(result); + } catch (error) { + return errorResponse(error.message); + } + } + + @Post('/update/:id') + async update(@Param('id') id: number, @Body() body) { + try { + const siteId = body.siteId; + if (!siteId) { + return errorResponse('siteId is required'); + } + // 过滤出需要更新的字段 + const { title, caption, description, alt_text } = body; + const data: any = {}; + if (title !== undefined) data.title = title; + if (caption !== undefined) data.caption = caption; + if (description !== undefined) data.description = description; + if (alt_text !== undefined) data.alt_text = alt_text; + + const result = await this.wpService.updateMedia(siteId, id, data); + return successResponse(result); + } catch (error) { + return errorResponse(error.message); + } + } + + @Del('/:id') + async delete(@Param('id') id: number, @Query('siteId') siteId: number, @Query('force') force: boolean = true) { + try { + if (!siteId) { + return errorResponse('siteId is required'); + } + const result = await this.wpService.deleteMedia(siteId, id, force); + return successResponse(result); + } catch (error) { + return errorResponse(error.message); + } + } +} diff --git a/src/controller/order.controller.ts b/src/controller/order.controller.ts index cdeb3ad..8aa7b5f 100644 --- a/src/controller/order.controller.ts +++ b/src/controller/order.controller.ts @@ -36,10 +36,10 @@ export class OrderController { type: BooleanRes, }) @Post('/syncOrder/:siteId') - async syncOrder(@Param('siteId') siteId: string) { + async syncOrder(@Param('siteId') siteId: number, @Body() params: Record) { try { - await this.orderService.syncOrders(siteId); - return successResponse(true); + const result = await this.orderService.syncOrders(siteId, params); + return successResponse(result); } catch (error) { console.log(error); return errorResponse('同步失败'); @@ -51,7 +51,7 @@ export class OrderController { }) @Post('/syncOrder/:siteId/order/:orderId') async syncOrderById( - @Param('siteId') siteId: string, + @Param('siteId') siteId: number, @Param('orderId') orderId: string ) { try { diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index cfdedb6..bc500cf 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -11,36 +11,19 @@ import { } from '@midwayjs/core'; import { ProductService } from '../service/product.service'; import { errorResponse, successResponse } from '../utils/response.util'; -import { - BatchSetSkuDTO, - CreateCategoryDTO, - CreateFlavorsDTO, - CreateProductDTO, - CreateStrengthDTO, - QueryCategoryDTO, - QueryFlavorsDTO, - QueryProductDTO, - QueryStrengthDTO, - UpdateCategoryDTO, - UpdateFlavorsDTO, - UpdateProductDTO, - UpdateStrengthDTO, -} from '../dto/product.dto'; +import { CreateProductDTO, QueryProductDTO, UpdateProductDTO, BatchUpdateProductDTO, BatchDeleteProductDTO } from '../dto/product.dto'; import { ApiOkResponse } from '@midwayjs/swagger'; -import { - BooleanRes, - ProductCatListRes, - ProductCatRes, - ProductListRes, - ProductRes, - ProductsRes, -} from '../dto/reponse.dto'; +import { BooleanRes, ProductListRes, ProductRes, ProductsRes } from '../dto/reponse.dto'; +import { ContentType, Files } from '@midwayjs/core'; +import { Context } from '@midwayjs/koa'; @Controller('/product') export class ProductController { @Inject() productService: ProductService; - ProductRes; + + @Inject() + ctx: Context; @ApiOkResponse({ description: '通过name搜索产品', @@ -79,12 +62,14 @@ export class ProductController { async getProductList( @Query() query: QueryProductDTO ): Promise { - const { current = 1, pageSize = 10, name, categoryId } = query; + const { current = 1, pageSize = 10, name, brandId, sortField, sortOrder } = query; try { const data = await this.productService.getProductList( { current, pageSize }, name, - categoryId + brandId, + sortField, + sortOrder ); return successResponse(data); } catch (error) { @@ -93,56 +78,146 @@ export class ProductController { } } - @ApiOkResponse({ - type: ProductRes, - }) + @ApiOkResponse({ type: ProductRes }) @Post('/') async createProduct(@Body() productData: CreateProductDTO) { try { - const data = this.productService.createProduct(productData); + const data = await this.productService.createProduct(productData); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: ProductRes, - }) + // 导出所有产品 CSV + @ApiOkResponse() + @Get('/export') + @ContentType('text/csv') + async exportProductsCSV() { + try { + const csv = await this.productService.exportProductsCSV(); + // 设置下载文件名(附件形式) + const date = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const name = `products-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}.csv`; + this.ctx.set('Content-Disposition', `attachment; filename=${name}`); + return csv; + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 导入产品(CSV 文件) + @ApiOkResponse() + @Post('/import') + async importProductsCSV(@Files() files: any) { + try { + // 条件判断:确保存在文件 + const file = files?.[0]; + if (!file) return errorResponse('未接收到上传文件'); + + const result = await this.productService.importProductsCSV(file); + return successResponse(result); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse({ type: ProductRes }) @Put('/:id') - async updateProduct( - @Param('id') id: number, - @Body() productData: UpdateProductDTO - ) { + async updateProduct(@Param('id') id: number, @Body() productData: UpdateProductDTO) { try { - const data = this.productService.updateProduct(id, productData); + const data = await this.productService.updateProduct(id, productData); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: ProductRes, - }) + @ApiOkResponse({ type: BooleanRes }) + @Put('/batch-update') + async batchUpdateProduct(@Body() batchUpdateProductDTO: BatchUpdateProductDTO) { + try { + await this.productService.batchUpdateProduct(batchUpdateProductDTO); + return successResponse(true); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse({ type: BooleanRes }) + @Post('/batch-delete') + async batchDeleteProduct(@Body() body: BatchDeleteProductDTO) { + try { + const result = await this.productService.batchDeleteProduct(body.ids); + if (result.failed > 0) { + return errorResponse(`成功删除 ${result.success} 个,失败 ${result.failed} 个。首个错误: ${result.errors[0]}`); + } + return successResponse(true); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse({ type: ProductRes }) @Put('updateNameCn/:id/:nameCn') - async updateProductNameCn( - @Param('id') id: number, - @Param('nameCn') nameCn: string - ) { + async updatenameCn(@Param('id') id: number, @Param('nameCn') nameCn: string) { try { - const data = this.productService.updateProductNameCn(id, nameCn); + const data = await this.productService.updatenameCn(id, nameCn); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - + // 根据站点SKU查询产品 + @ApiOkResponse({ type: ProductRes }) + @Get('/site-sku/:siteSku') + async getProductBySiteSku(@Param('siteSku') siteSku: string) { + try { + const product = await this.productService.findProductBySiteSku(siteSku); + return successResponse(product); + } catch (error) { + return errorResponse(error.message || '获取数据失败'); + } + } - @ApiOkResponse({ - type: BooleanRes, - }) + // 获取产品的站点SKU绑定 + @ApiOkResponse() + @Get('/:id/site-skus') + async getProductSiteSkus(@Param('id') id: number) { + try { + const data = await this.productService.getProductSiteSkus(id); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 覆盖式绑定产品的站点SKU列表 + @ApiOkResponse() + @Post('/:id/site-skus') + async bindProductSiteSkus(@Param('id') id: number, @Body() body: { codes: string[] }) { + try { + const data = await this.productService.bindSiteSkus(id, body?.codes || []); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse({ type: ProductRes }) + @Get('/:id') + async getProductById(@Param('id') id: number) { + try { + const product = await this.productService.getProductById(id); + return successResponse(product); + } catch (error) { + return errorResponse(error.message || '获取数据失败'); + } + } + + @ApiOkResponse({ type: BooleanRes }) @Del('/:id') async deleteProduct(@Param('id') id: number) { try { @@ -153,14 +228,58 @@ export class ProductController { } } - @ApiOkResponse({ - type: ProductCatListRes, - }) - @Get('/categories') - async getCategories(@Query() query: QueryCategoryDTO) { - const { current = 1, pageSize = 10, name } = query; + // 获取产品的库存组成 + @ApiOkResponse() + @Get('/:id/components') + async getProductComponents(@Param('id') id: number) { try { - let data = await this.productService.getCategoryList( + const data = await this.productService.getProductComponents(id); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + + // 根据 SKU 自动绑定组成(匹配所有相同 SKU 的库存) + @ApiOkResponse() + @Post('/:id/components/auto') + async autoBindComponents(@Param('id') id: number) { + try { + const data = await this.productService.autoBindComponentsBySku(id); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + + // 获取所有 WordPress 商品 + @ApiOkResponse({ description: '获取所有 WordPress 商品' }) + @Get('/wp-products') + async getWpProducts() { + try { + const data = await this.productService.getWpProducts(); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + + + // 通用属性接口:分页列表 + @ApiOkResponse() + @Get('/attribute') + async getAttributeList( + @Query('dictName') dictName: string, + @Query('current') current = 1, + @Query('pageSize') pageSize = 10, + @Query('name') name?: string + ) { + try { + const data = await this.productService.getAttributeList( + dictName, { current, pageSize }, name ); @@ -170,92 +289,138 @@ export class ProductController { } } + // 通用属性接口:全部列表 @ApiOkResponse() - @Get('/categorieAll') - async getCategorieAll() { + @Get('/attributeAll') + async getAttributeAll(@Query('dictName') dictName: string) { try { - let data = await this.productService.getCategoryAll(); + const data = await this.productService.getAttributeAll(dictName); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: ProductCatRes, - }) - @Post('/category') - async createCategory(@Body() categoryData: CreateCategoryDTO) { - try { - const hasCategory = await this.productService.hasCategory( - categoryData.name - ); - if (hasCategory) { - return errorResponse('分类已存在'); - } - let data = await this.productService.createCategory(categoryData); - return successResponse(data); - } catch (error) { - return errorResponse(error?.message || error); - } - } - - @ApiOkResponse({ - type: ProductCatRes, - }) - @Put('/category/:id') - async updateCategory( - @Param('id') id: number, - @Body() categoryData: UpdateCategoryDTO + // 通用属性接口:创建 + @ApiOkResponse() + @Post('/attribute') + async createAttribute( + @Query('dictName') dictName: string, + @Body() body: { title: string; name: string } ) { try { - const hasCategory = await this.productService.hasCategory( - categoryData.name - ); - if (hasCategory) { - return errorResponse('分类已存在'); + // 调用 getOrCreateAttribute 方法,如果不存在则创建,如果存在则返回 + const data = await this.productService.getOrCreateAttribute(dictName, body.title, body.name); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 通用属性接口:更新 + @ApiOkResponse() + @Put('/attribute/:id') + async updateAttribute( + @Param('id') id: number, + @Query('dictName') dictName: string, + @Body() body: { title?: string; name?: string } + ) { + try { + if (body?.name) { + const hasItem = await this.productService.hasAttribute( + dictName, + body.name, + id + ); + if (hasItem) return errorResponse('字典项已存在'); } - const data = this.productService.updateCategory(id, categoryData); + const data = await this.productService.updateAttribute(id, body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: BooleanRes, - }) - @Del('/category/:id') - async deleteCategory(@Param('id') id: number) { + // 通用属性接口:删除 + @ApiOkResponse({ type: BooleanRes }) + @Del('/attribute/:id') + async deleteAttribute(@Param('id') id: number) { try { - const hasProducts = await this.productService.hasProductsInCategory(id); - if (hasProducts) throw new Error('该分类下有商品,无法删除'); - const data = await this.productService.deleteCategory(id); - return successResponse(data); + await this.productService.deleteAttribute(id); + return successResponse(true); } catch (error) { return errorResponse(error?.message || error); } } - @Post('/batchSetSku') - @ApiOkResponse({ - description: '批量设置 sku 的响应结果', - type: BooleanRes, - }) - async batchSetSku(@Body() body: BatchSetSkuDTO) { + // 兼容旧接口:品牌 + @ApiOkResponse() + @Get('/brandAll') + async compatBrandAll() { try { - const result = await this.productService.batchSetSku(body.skus); - return successResponse(result, '批量设置 sku 成功'); + const data = await this.productService.getAttributeAll('brand'); // 返回所有品牌字典项 + return successResponse(data); } catch (error) { - return errorResponse(error.message, 400); + return errorResponse(error?.message || error); } } @ApiOkResponse() - @Get('/flavorsAll') - async getFlavorsAll() { + @Get('/brands') + async compatBrands(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { try { - let data = await this.productService.getFlavorsAll(); + const data = await this.productService.getAttributeList('brand', { current, pageSize }, name); // 分页品牌列表 + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Post('/brand') + async compatCreateBrand(@Body() body: { title: string; name: string; image?: string; shortName?: string }) { + try { + const has = await this.productService.hasAttribute('brand', body.name); // 唯一性校验 + if (has) return errorResponse('品牌已存在'); + const data = await this.productService.createAttribute('brand', body); // 创建品牌字典项 + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Put('/brand/:id') + async compatUpdateBrand(@Param('id') id: number, @Body() body: { title?: string; name?: string; image?: string; shortName?: string }) { + try { + if (body?.name) { + const has = await this.productService.hasAttribute('brand', body.name, id); // 唯一性校验(排除自身) + if (has) return errorResponse('品牌已存在'); + } + const data = await this.productService.updateAttribute(id, body); // 更新品牌字典项 + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse({ type: BooleanRes }) + @Del('/brand/:id') + async compatDeleteBrand(@Param('id') id: number) { + try { + await this.productService.deleteAttribute(id); // 删除品牌字典项 + return successResponse(true); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 兼容旧接口:口味 + @ApiOkResponse() + @Get('/flavorsAll') + async compatFlavorsAll() { + try { + const data = await this.productService.getAttributeAll('flavor'); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -264,13 +429,9 @@ export class ProductController { @ApiOkResponse() @Get('/flavors') - async getFlavors(@Query() query: QueryFlavorsDTO) { - const { current = 1, pageSize = 10, name } = query; + async compatFlavors(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { try { - let data = await this.productService.getFlavorsList( - { current, pageSize }, - name - ); + const data = await this.productService.getAttributeList('flavor', { current, pageSize }, name); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -279,13 +440,11 @@ export class ProductController { @ApiOkResponse() @Post('/flavors') - async createFlavors(@Body() flavorsData: CreateFlavorsDTO) { + async compatCreateFlavors(@Body() body: { title: string; name: string; image?: string; shortName?: string }) { try { - const hasFlavors = await this.productService.hasFlavors(flavorsData.name); - if (hasFlavors) { - return errorResponse('分类已存在'); - } - let data = await this.productService.createFlavors(flavorsData); + const has = await this.productService.hasAttribute('flavor', body.name); + if (has) return errorResponse('口味已存在'); + const data = await this.productService.createAttribute('flavor', body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -294,42 +453,36 @@ export class ProductController { @ApiOkResponse() @Put('/flavors/:id') - async updateFlavors( - @Param('id') id: number, - @Body() flavorsData: UpdateFlavorsDTO - ) { + async compatUpdateFlavors(@Param('id') id: number, @Body() body: { title?: string; name?: string; image?: string; shortName?: string }) { try { - const hasFlavors = await this.productService.hasFlavors(flavorsData.name); - if (hasFlavors) { - return errorResponse('分类已存在'); + if (body?.name) { + const has = await this.productService.hasAttribute('flavor', body.name, id); + if (has) return errorResponse('口味已存在'); } - const data = this.productService.updateFlavors(id, flavorsData); + const data = await this.productService.updateAttribute(id, body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: BooleanRes, - }) + @ApiOkResponse({ type: BooleanRes }) @Del('/flavors/:id') - async deleteFlavors(@Param('id') id: number) { + async compatDeleteFlavors(@Param('id') id: number) { try { - const hasProducts = await this.productService.hasProductsInFlavors(id); - if (hasProducts) throw new Error('该分类下有商品,无法删除'); - const data = await this.productService.deleteFlavors(id); - return successResponse(data); + await this.productService.deleteAttribute(id); + return successResponse(true); } catch (error) { return errorResponse(error?.message || error); } } + // 兼容旧接口:规格 @ApiOkResponse() @Get('/strengthAll') - async getStrengthAll() { + async compatStrengthAll() { try { - let data = await this.productService.getStrengthAll(); + const data = await this.productService.getAttributeAll('strength'); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -338,13 +491,9 @@ export class ProductController { @ApiOkResponse() @Get('/strength') - async getStrength(@Query() query: QueryStrengthDTO) { - const { current = 1, pageSize = 10, name } = query; + async compatStrength(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { try { - let data = await this.productService.getStrengthList( - { current, pageSize }, - name - ); + const data = await this.productService.getAttributeList('strength', { current, pageSize }, name); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -353,15 +502,11 @@ export class ProductController { @ApiOkResponse() @Post('/strength') - async createStrength(@Body() strengthData: CreateStrengthDTO) { + async compatCreateStrength(@Body() body: { title: string; name: string; image?: string; shortName?: string }) { try { - const hasStrength = await this.productService.hasStrength( - strengthData.name - ); - if (hasStrength) { - return errorResponse('分类已存在'); - } - let data = await this.productService.createStrength(strengthData); + const has = await this.productService.hasAttribute('strength', body.name); + if (has) return errorResponse('规格已存在'); + const data = await this.productService.createAttribute('strength', body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -370,33 +515,182 @@ export class ProductController { @ApiOkResponse() @Put('/strength/:id') - async updateStrength( - @Param('id') id: number, - @Body() strengthData: UpdateStrengthDTO - ) { + async compatUpdateStrength(@Param('id') id: number, @Body() body: { title?: string; name?: string; image?: string; shortName?: string }) { try { - const hasStrength = await this.productService.hasStrength( - strengthData.name - ); - if (hasStrength) { - return errorResponse('分类已存在'); + if (body?.name) { + const has = await this.productService.hasAttribute('strength', body.name, id); + if (has) return errorResponse('规格已存在'); } - const data = this.productService.updateStrength(id, strengthData); + const data = await this.productService.updateAttribute(id, body); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); } } - @ApiOkResponse({ - type: BooleanRes, - }) + @ApiOkResponse({ type: BooleanRes }) @Del('/strength/:id') - async deleteStrength(@Param('id') id: number) { + async compatDeleteStrength(@Param('id') id: number) { try { - const hasProducts = await this.productService.hasProductsInStrength(id); - if (hasProducts) throw new Error('该分类下有商品,无法删除'); - const data = await this.productService.deleteStrength(id); + await this.productService.deleteAttribute(id); + return successResponse(true); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 兼容旧接口:尺寸 + @ApiOkResponse() + @Get('/sizeAll') + async compatSizeAll() { + try { + const data = await this.productService.getAttributeAll('size'); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Get('/size') + async compatSize(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { + try { + const data = await this.productService.getAttributeList('size', { current, pageSize }, name); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Post('/size') + async compatCreateSize(@Body() body: { title: string; name: string; image?: string; shortName?: string }) { + try { + const has = await this.productService.hasAttribute('size', body.name); + if (has) return errorResponse('尺寸已存在'); + const data = await this.productService.createAttribute('size', body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Put('/size/:id') + async compatUpdateSize(@Param('id') id: number, @Body() body: { title?: string; name?: string; image?: string; shortName?: string }) { + try { + if (body?.name) { + const has = await this.productService.hasAttribute('size', body.name, id); + if (has) return errorResponse('尺寸已存在'); + } + const data = await this.productService.updateAttribute(id, body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse({ type: BooleanRes }) + @Del('/size/:id') + async compatDeleteSize(@Param('id') id: number) { + try { + await this.productService.deleteAttribute(id); + return successResponse(true); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 获取所有分类 + @ApiOkResponse({ description: '获取所有分类' }) + @Get('/categories/all') + async getCategoriesAll() { + try { + const data = await this.productService.getCategoriesAll(); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 获取分类下的属性配置 + @ApiOkResponse({ description: '获取分类下的属性配置' }) + @Get('/category/:id/attributes') + async getCategoryAttributes(@Param('id') id: number) { + try { + const data = await this.productService.getCategoryAttributes(id); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 创建分类 + @ApiOkResponse({ description: '创建分类' }) + @Post('/category') + async createCategory(@Body() body: any) { + try { + const data = await this.productService.createCategory(body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 更新分类 + @ApiOkResponse({ description: '更新分类' }) + @Put('/category/:id') + async updateCategory(@Param('id') id: number, @Body() body: any) { + try { + const data = await this.productService.updateCategory(id, body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 删除分类 + @ApiOkResponse({ description: '删除分类' }) + @Del('/category/:id') + async deleteCategory(@Param('id') id: number) { + try { + await this.productService.deleteCategory(id); + return successResponse(true); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 创建分类属性 + @ApiOkResponse({ description: '创建分类属性' }) + @Post('/category/attribute') + async createCategoryAttribute(@Body() body: { categoryId: number; dictId: number }) { + try { + const data = await this.productService.createCategoryAttribute(body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 删除分类属性 + @ApiOkResponse({ description: '删除分类属性' }) + @Del('/category/attribute/:id') + async deleteCategoryAttribute(@Param('id') id: number) { + try { + await this.productService.deleteCategoryAttribute(id); + return successResponse(true); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + // 同步库存 SKU 到产品单品 + @ApiOkResponse({ description: '同步库存 SKU 到产品单品' }) + @Post('/sync-stock') + async syncStockToProduct() { + try { + const data = await this.productService.syncStockToProduct(); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); diff --git a/src/controller/site-api.controller.ts b/src/controller/site-api.controller.ts new file mode 100644 index 0000000..0518cba --- /dev/null +++ b/src/controller/site-api.controller.ts @@ -0,0 +1,1423 @@ +import { Controller, Get, Inject, Param, Query, Body, Post, Put, Del } from '@midwayjs/core'; +import { ApiOkResponse, ApiBody } from '@midwayjs/swagger'; +import { + UploadMediaDTO, + UnifiedMediaPaginationDTO, + UnifiedOrderDTO, + UnifiedOrderPaginationDTO, + UnifiedProductDTO, + UnifiedProductPaginationDTO, + UnifiedSearchParamsDTO, + UnifiedSubscriptionPaginationDTO, + UnifiedCustomerDTO, + UnifiedCustomerPaginationDTO, + UnifiedReviewPaginationDTO, + UnifiedReviewDTO, + CreateReviewDTO, + UpdateReviewDTO, + UnifiedWebhookDTO, + CreateWebhookDTO, + UpdateWebhookDTO, + UnifiedPaginationDTO, + ShipOrderDTO, + CancelShipOrderDTO, + BatchShipOrdersDTO, +} from '../dto/site-api.dto'; +import { SiteApiService } from '../service/site-api.service'; +import { errorResponse, successResponse } from '../utils/response.util'; +import { ILogger } from '@midwayjs/core'; + + +@Controller('/site-api') +export class SiteApiController { + @Inject() + siteApiService: SiteApiService; + + @Inject() + logger: ILogger; + + + + + + @Post('/:siteId/media/create') + @ApiOkResponse({ type: UnifiedMediaPaginationDTO }) + @ApiBody({ type: UploadMediaDTO }) + async createMedia( + @Param('siteId') siteId: number, + @Body() body: UploadMediaDTO, + ) { + this.logger.debug(`[Site API] 上传媒体文件开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createMedia(body.file); + this.logger.debug(`[Site API] 上传媒体文件成功, siteId: ${siteId}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 上传媒体文件失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + + @Get('/:siteId/reviews') + @ApiOkResponse({ type: UnifiedReviewPaginationDTO }) + async getReviews( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.debug(`[Site API] 获取评论列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getReviews(query); + this.logger.debug(`[Site API] 获取评论列表成功, siteId: ${siteId}, 共获取到 ${data.total} 条评论`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取评论列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/reviews') + @ApiOkResponse({ type: UnifiedReviewDTO }) + async createReview( + @Param('siteId') siteId: number, + @Body() body: CreateReviewDTO + ) { + this.logger.debug(`[Site API] 创建评论开始, siteId: ${siteId}, body: ${JSON.stringify(body)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createReview(body); + this.logger.debug(`[Site API] 创建评论成功, siteId: ${siteId}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建评论失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/reviews/:id') + @ApiOkResponse({ type: UnifiedReviewDTO }) + async updateReview( + @Param('siteId') siteId: number, + @Param('id') id: number, + @Body() body: UpdateReviewDTO + ) { + this.logger.debug(`[Site API] 更新评论开始, siteId: ${siteId}, id: ${id}, body: ${JSON.stringify(body)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.updateReview(id, body); + this.logger.debug(`[Site API] 更新评论成功, siteId: ${siteId}, id: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 更新评论失败, siteId: ${siteId}, id: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/reviews/:id') + @ApiOkResponse({ type: Boolean }) + async deleteReview( + @Param('siteId') siteId: number, + @Param('id') id: number + ) { + this.logger.debug(`[Site API] 删除评论开始, siteId: ${siteId}, id: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.deleteReview(id); + this.logger.debug(`[Site API] 删除评论成功, siteId: ${siteId}, id: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 删除评论失败, siteId: ${siteId}, id: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/webhooks') + @ApiOkResponse({ type: UnifiedPaginationDTO }) + async getWebhooks( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.debug(`[Site API] 获取webhooks列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getWebhooks(query); + this.logger.debug(`[Site API] 获取webhooks列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个webhooks`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取webhooks列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/webhooks/:id') + @ApiOkResponse({ type: UnifiedWebhookDTO }) + async getWebhook( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.debug(`[Site API] 获取单个webhook开始, siteId: ${siteId}, id: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getWebhook(id); + this.logger.debug(`[Site API] 获取单个webhook成功, siteId: ${siteId}, id: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取单个webhook失败, siteId: ${siteId}, id: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/webhooks') + @ApiOkResponse({ type: UnifiedWebhookDTO }) + @ApiBody({ type: CreateWebhookDTO }) + async createWebhook( + @Param('siteId') siteId: number, + @Body() body: CreateWebhookDTO + ) { + this.logger.debug(`[Site API] 创建webhook开始, siteId: ${siteId}, body: ${JSON.stringify(body)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createWebhook(body); + this.logger.debug(`[Site API] 创建webhook成功, siteId: ${siteId}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建webhook失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/webhooks/:id') + @ApiOkResponse({ type: UnifiedWebhookDTO }) + @ApiBody({ type: UpdateWebhookDTO }) + async updateWebhook( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: UpdateWebhookDTO + ) { + this.logger.debug(`[Site API] 更新webhook开始, siteId: ${siteId}, id: ${id}, body: ${JSON.stringify(body)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.updateWebhook(id, body); + this.logger.debug(`[Site API] 更新webhook成功, siteId: ${siteId}, id: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 更新webhook失败, siteId: ${siteId}, id: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/webhooks/:id') + @ApiOkResponse({ type: Boolean }) + async deleteWebhook( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.debug(`[Site API] 删除webhook开始, siteId: ${siteId}, id: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.deleteWebhook(id); + this.logger.debug(`[Site API] 删除webhook成功, siteId: ${siteId}, id: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 删除webhook失败, siteId: ${siteId}, id: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/products') + @ApiOkResponse({ type: UnifiedProductPaginationDTO }) + async getProducts( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.debug(`[Site API] 获取产品列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getProducts(query); + + // 如果包含ERP产品信息,则增强商品数据 + if (data && data.items && data.items.length > 0) { + const enrichedItems = await this.siteApiService.enrichSiteProductsWithErpInfo(siteId, data.items); + data.items = enrichedItems; + } + + this.logger.debug(`[Site API] 获取产品列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个产品`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取产品列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/products/export') + async exportProducts( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const perPage = (query.per_page) || 100; + let page = 1; + const all: any[] = []; + while (true) { + const data = await adapter.getProducts({ ...query, page, per_page: perPage }); + const items = data.items || []; + all.push(...items); + const totalPages = data.totalPages || Math.ceil((data.total || 0) / (data.per_page || perPage)); + if (!items.length || page >= totalPages) break; + page += 1; + } + let items = all; + if (query.ids) { + const ids = new Set(String(query.ids).split(',').map(v => v.trim()).filter(Boolean)); + items = items.filter(i => ids.has(String(i.id))); + } + const header = ['id', 'name', 'type', 'status', 'sku', 'regular_price', 'sale_price', 'price', 'stock_status', 'stock_quantity', 'image_src']; + const rows = items.map((p: any) => [ + p.id, + p.name, + p.type, + p.status, + p.sku, + p.regular_price, + p.sale_price, + p.price, + p.stock_status, + p.stock_quantity, + ((p.images && p.images[0]?.src) || ''), + ]); + const toCsvValue = (val: any) => { + const s = String(val ?? ''); + const escaped = s.replace(/"/g, '""'); + return `"${escaped}"`; + }; + const csv = [header.map(toCsvValue).join(','), ...rows.map(r => r.map(toCsvValue).join(','))].join('\n'); + return successResponse({ csv }); + } catch (error) { + return errorResponse(error.message); + } + } + + // 平台特性:产品导出(特殊CSV,走平台服务) + @Get('/:siteId/links') + async getLinks( + @Param('siteId') siteId: number + ) { + this.logger.debug(`[Site API] 获取站点链接列表开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getLinks(); + this.logger.debug(`[Site API] 获取站点链接列表成功, siteId: ${siteId}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取站点链接列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/products/export-special') + async exportProductsSpecial( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + try { + const site = await this.siteApiService.siteService.get(siteId, true); + if (site.type === 'woocommerce') { + const page = query.page || 1; + const perPage = (query.per_page) || 100; + const res = await this.siteApiService.wpService.getProducts(site, page, perPage); + const header = ['id', 'name', 'type', 'status', 'sku', 'regular_price', 'sale_price', 'stock_status', 'stock_quantity']; + const rows = (res.items || []).map((p: any) => [p.id, p.name, p.type, p.status, p.sku, p.regular_price, p.sale_price, p.stock_status, p.stock_quantity]); + const toCsvValue = (val: any) => { + const s = String(val ?? ''); + const escaped = s.replace(/"/g, '""'); + return `"${escaped}"`; + }; + const csv = [header.map(toCsvValue).join(','), ...rows.map(r => r.map(toCsvValue).join(','))].join('\n'); + return successResponse({ csv }); + } + if (site.type === 'shopyy') { + const res = await this.siteApiService.shopyyService.getProducts(site, query.page || 1, query.per_page || 100); + const header = ['id', 'name', 'type', 'status', 'sku', 'price', 'stock_status', 'stock_quantity']; + const rows = (res.items || []).map((p: any) => [p.id, p.name, p.type, p.status, p.sku, p.price, p.stock_status, p.stock_quantity]); + const csv = [header.join(','), ...rows.map(r => r.map(v => String(v ?? '')).join(','))].join('\n'); + return successResponse({ csv }); + } + throw new Error('Unsupported site type for special export'); + } catch (error) { + return errorResponse(error.message); + } + } + + @Get('/:siteId/products/:id') + @ApiOkResponse({ type: UnifiedProductDTO }) + async getProduct( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 获取单个产品开始, siteId: ${siteId}, productId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getProduct(id); + + // 如果获取到商品数据,则增强ERP产品信息 + if (data) { + const enrichedData = await this.siteApiService.enrichSiteProductWithErpInfo(siteId, data); + this.logger.info(`[Site API] 获取单个产品成功, siteId: ${siteId}, productId: ${id}`); + return successResponse(enrichedData); + } + + this.logger.info(`[Site API] 获取单个产品成功, siteId: ${siteId}, productId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取单个产品失败, siteId: ${siteId}, productId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/products') + @ApiOkResponse({ type: UnifiedProductDTO }) + async createProduct( + @Param('siteId') siteId: number, + @Body() body: UnifiedProductDTO + ) { + this.logger.info(`[Site API] 创建产品开始, siteId: ${siteId}, 产品名称: ${body.name}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createProduct(body); + this.logger.info(`[Site API] 创建产品成功, siteId: ${siteId}, 产品ID: ${data.id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建产品失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/products/import') + @ApiOkResponse({ type: Object }) + async importProducts( + @Param('siteId') siteId: number, + @Body() body: { items?: any[]; csv?: string } + ) { + try { + const adapter = await this.siteApiService.getAdapter(siteId); + let items = body.items || []; + if (!items.length && body.csv) { + const lines = body.csv.split(/\r?\n/).filter(Boolean); + const header = lines.shift()?.split(',') || []; + items = lines.map((line) => { + const cols = line.split(','); + const obj: any = {}; + header.forEach((h, i) => (obj[h] = cols[i])); + return obj; + }); + } + const created: any[] = []; + const failed: any[] = []; + for (const item of items) { + try { + const data = await adapter.createProduct(item); + created.push(data); + } catch (e) { + failed.push({ item, error: (e as any).message }); + } + } + return successResponse({ created, failed }); + } catch (error) { + return errorResponse(error.message); + } + } + + // 平台特性:产品导入(特殊CSV,走平台服务) + @Post('/:siteId/products/import-special') + @ApiOkResponse({ type: Object }) + async importProductsSpecial( + @Param('siteId') siteId: number, + @Body() body: { csv?: string; items?: any[] } + ) { + try { + const site = await this.siteApiService.siteService.get(siteId, true); + const csvText = body.csv || ''; + const items = body.items || []; + const created: any[] = []; + const failed: any[] = []; + if (site.type === 'woocommerce') { + // 解析 CSV 为对象数组(若传入 items 则优先 items) + let payloads = items; + if (!payloads.length && csvText) { + const lines = csvText.split(/\r?\n/).filter(Boolean); + const header = lines.shift()?.split(',') || []; + payloads = lines.map((line) => { + const cols = line.split(','); + const obj: any = {}; + header.forEach((h, i) => (obj[h] = cols[i])); + return obj; + }); + } + for (const item of payloads) { + try { + const res = await this.siteApiService.wpService.createProduct(site, item); + created.push(res); + } catch (e) { + failed.push({ item, error: (e as any).message }); + } + } + return successResponse({ created, failed }); + } + if (site.type === 'shopyy') { + throw new Error('ShopYY 暂不支持特殊CSV导入'); + } + throw new Error('Unsupported site type for special import'); + } catch (error) { + return errorResponse(error.message); + } + } + + @Put('/:siteId/products/:id') + @ApiOkResponse({ type: UnifiedProductDTO }) + async updateProduct( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: UnifiedProductDTO + ) { + this.logger.info(`[Site API] 更新产品开始, siteId: ${siteId}, productId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.updateProduct(id, body); + this.logger.info(`[Site API] 更新产品成功, siteId: ${siteId}, productId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 更新产品失败, siteId: ${siteId}, productId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/products/:productId/variations/:variationId') + @ApiOkResponse({ type: Object }) + async updateVariation( + @Param('siteId') siteId: number, + @Param('productId') productId: string, + @Param('variationId') variationId: string, + @Body() body: any + ) { + this.logger.info(`[Site API] 更新产品变体开始, siteId: ${siteId}, productId: ${productId}, variationId: ${variationId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.updateVariation(productId, variationId, body); + this.logger.info(`[Site API] 更新产品变体成功, siteId: ${siteId}, productId: ${productId}, variationId: ${variationId}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 更新产品变体失败, siteId: ${siteId}, productId: ${productId}, variationId: ${variationId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/products/:id') + @ApiOkResponse({ type: Boolean }) + async deleteProduct( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 删除产品开始, siteId: ${siteId}, productId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const success = await adapter.deleteProduct(id); + this.logger.info(`[Site API] 删除产品成功, siteId: ${siteId}, productId: ${id}`); + return successResponse(success); + } catch (error) { + this.logger.error(`[Site API] 删除产品失败, siteId: ${siteId}, productId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/products/batch') + @ApiOkResponse({ type: Object }) + async batchProducts( + @Param('siteId') siteId: number, + @Body() body: { create?: any[]; update?: any[]; delete?: Array } + ) { + this.logger.info(`[Site API] 批量处理产品开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + if (adapter.batchProcessProducts) { + const res = await adapter.batchProcessProducts(body); + this.logger.info(`[Site API] 批量处理产品成功, siteId: ${siteId}`); + return successResponse(res); + } + const created: any[] = []; + const updated: any[] = []; + const deleted: Array = []; + const failed: any[] = []; + if (body.create?.length) { + for (const item of body.create) { + try { + const data = await adapter.createProduct(item); + created.push(data); + } catch (e) { + failed.push({ action: 'create', item, error: (e as any).message }); + } + } + } + if (body.update?.length) { + for (const item of body.update) { + try { + const id = item.id; + const data = await adapter.updateProduct(id, item); + updated.push(data); + } catch (e) { + failed.push({ action: 'update', item, error: (e as any).message }); + } + } + } + if (body.delete?.length) { + for (const id of body.delete) { + try { + const ok = await adapter.deleteProduct(id); + if (ok) deleted.push(id); + else failed.push({ action: 'delete', id, error: 'delete failed' }); + } catch (e) { + failed.push({ action: 'delete', id, error: (e as any).message }); + } + } + } + this.logger.info(`[Site API] 批量处理产品完成, siteId: ${siteId}`); + return successResponse({ created, updated, deleted, failed }); + } catch (error) { + this.logger.error(`[Site API] 批量处理产品失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/orders') + @ApiOkResponse({ type: UnifiedOrderPaginationDTO }) + async getOrders( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取订单列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const where = { ...(query.where || {}) }; + if (query.customer_id) { + where.customer = query.customer_id; + where.customer_id = query.customer_id; + } + const data = await adapter.getOrders({ ...query, where }); + this.logger.info(`[Site API] 获取订单列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个订单`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取订单列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/customers/:customerId/orders') + @ApiOkResponse({ type: UnifiedOrderPaginationDTO }) + async getCustomerOrders( + @Param('siteId') siteId: number, + @Param('customerId') customerId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取客户订单列表开始, siteId: ${siteId}, customerId: ${customerId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const where = { ...(query.where || {}), customer: customerId, customer_id: customerId }; + const data = await adapter.getOrders({ ...query, where, customer_id: customerId }); + this.logger.info(`[Site API] 获取客户订单列表成功, siteId: ${siteId}, customerId: ${customerId}, 共获取到 ${data.total} 个订单`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取客户订单列表失败, siteId: ${siteId}, customerId: ${customerId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/orders/export') + async exportOrders( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const perPage = (query.per_page) || 100; + let page = 1; + const all: any[] = []; + while (true) { + const data = await adapter.getOrders({ ...query, page, per_page: perPage }); + const items = data.items || []; + all.push(...items); + const totalPages = data.totalPages || Math.ceil((data.total || 0) / (data.per_page || perPage)); + if (!items.length || page >= totalPages) break; + page += 1; + } + let items = all; + if (query.ids) { + const ids = new Set(String(query.ids).split(',').map(v => v.trim()).filter(Boolean)); + items = items.filter(i => ids.has(String(i.id))); + } + const header = ['id', 'number', 'status', 'currency', 'total', 'customer_id', 'customer_name', 'email', 'payment_method', 'phone', 'billing_full_address', 'shipping_full_address', 'date_created']; + const rows = items.map((o: any) => [ + o.id, + o.number, + o.status, + o.currency, + o.total, + o.customer_id, + o.customer_name, + o.email, + o.payment_method, + (o.shipping?.phone || o.billing?.phone || ''), + (o.billing_full_address || ''), + (o.shipping_full_address || ''), + o.date_created, + ]); + const toCsvValue = (val: any) => { + const s = String(val ?? ''); + const escaped = s.replace(/"/g, '""'); + return `"${escaped}"`; + }; + const csv = [header.map(toCsvValue).join(','), ...rows.map(r => r.map(toCsvValue).join(','))].join('\n'); + return successResponse({ csv }); + } catch (error) { + return errorResponse(error.message); + } + } + + @Get('/:siteId/orders/:id') + @ApiOkResponse({ type: UnifiedOrderDTO }) + async getOrder( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 获取单个订单开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getOrder(id); + this.logger.info(`[Site API] 获取单个订单成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取单个订单失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders') + @ApiOkResponse({ type: UnifiedOrderDTO }) + async createOrder( + @Param('siteId') siteId: number, + @Body() body: any + ) { + this.logger.info(`[Site API] 创建订单开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createOrder(body); + this.logger.info(`[Site API] 创建订单成功, siteId: ${siteId}, orderId: ${data.id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建订单失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders/import') + @ApiOkResponse({ type: Object }) + async importOrders( + @Param('siteId') siteId: number, + @Body() body: { items?: any[]; csv?: string } + ) { + try { + const adapter = await this.siteApiService.getAdapter(siteId); + let items = body.items || []; + if (!items.length && body.csv) { + const lines = body.csv.split(/\r?\n/).filter(Boolean); + const header = lines.shift()?.split(',') || []; + items = lines.map((line) => { + const cols = line.split(','); + const obj: any = {}; + header.forEach((h, i) => (obj[h] = cols[i])); + return obj; + }); + } + const created: any[] = []; + const failed: any[] = []; + for (const item of items) { + try { + const data = await adapter.createOrder(item); + created.push(data); + } catch (e) { + failed.push({ item, error: (e as any).message }); + } + } + return successResponse({ created, failed }); + } catch (error) { + return errorResponse(error.message); + } + } + + @Put('/:siteId/orders/:id') + @ApiOkResponse({ type: Boolean }) + async updateOrder( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: any + ) { + this.logger.info(`[Site API] 更新订单开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const ok = await adapter.updateOrder(id, body); + this.logger.info(`[Site API] 更新订单成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(ok); + } catch (error) { + this.logger.error(`[Site API] 更新订单失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/orders/:id') + @ApiOkResponse({ type: Boolean }) + async deleteOrder( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 删除订单开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const ok = await adapter.deleteOrder(id); + this.logger.info(`[Site API] 删除订单成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(ok); + } catch (error) { + this.logger.error(`[Site API] 删除订单失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders/batch') + @ApiOkResponse({ type: Object }) + async batchOrders( + @Param('siteId') siteId: number, + @Body() body: { create?: any[]; update?: any[]; delete?: Array } + ) { + this.logger.info(`[Site API] 批量处理订单开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const created: any[] = []; + const updated: any[] = []; + const deleted: Array = []; + const failed: any[] = []; + if (body.create?.length) { + for (const item of body.create) { + try { + const data = await adapter.createOrder(item); + created.push(data); + } catch (e) { + failed.push({ action: 'create', item, error: (e as any).message }); + } + } + } + if (body.update?.length) { + for (const item of body.update) { + try { + const id = item.id; + const ok = await adapter.updateOrder(id, item); + if (ok) updated.push(item); + else failed.push({ action: 'update', item, error: 'update failed' }); + } catch (e) { + failed.push({ action: 'update', item, error: (e as any).message }); + } + } + } + if (body.delete?.length) { + for (const id of body.delete) { + try { + const ok = await adapter.deleteOrder(id); + if (ok) deleted.push(id); + else failed.push({ action: 'delete', id, error: 'delete failed' }); + } catch (e) { + failed.push({ action: 'delete', id, error: (e as any).message }); + } + } + } + this.logger.info(`[Site API] 批量处理订单完成, siteId: ${siteId}`); + return successResponse({ created, updated, deleted, failed }); + } catch (error) { + this.logger.error(`[Site API] 批量处理订单失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/orders/:id/notes') + @ApiOkResponse({ type: Object }) + async getOrderNotes( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 获取订单备注开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getOrderNotes(id); + this.logger.info(`[Site API] 获取订单备注成功, siteId: ${siteId}, orderId: ${id}, 共获取到 ${data.length} 条备注`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取订单备注失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders/:id/notes') + @ApiOkResponse({ type: Object }) + async createOrderNote( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: any + ) { + this.logger.info(`[Site API] 创建订单备注开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createOrderNote(id, body); + this.logger.info(`[Site API] 创建订单备注成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建订单备注失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders/:id/ship') + @ApiOkResponse({ type: Object }) + async shipOrder( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: ShipOrderDTO + ) { + this.logger.info(`[Site API] 订单发货开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.shipOrder(id, body); + this.logger.info(`[Site API] 订单发货成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 订单发货失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders/:id/cancel-ship') + @ApiOkResponse({ type: Object }) + async cancelShipOrder( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: CancelShipOrderDTO + ) { + this.logger.info(`[Site API] 取消订单发货开始, siteId: ${siteId}, orderId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.cancelShipOrder(id, body); + this.logger.info(`[Site API] 取消订单发货成功, siteId: ${siteId}, orderId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 取消订单发货失败, siteId: ${siteId}, orderId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/orders/batch-ship') + @ApiOkResponse({ type: Object }) + async batchShipOrders( + @Param('siteId') siteId: number, + @Body() body: BatchShipOrdersDTO + ) { + this.logger.info(`[Site API] 批量订单发货开始, siteId: ${siteId}, 订单数量: ${body.orders.length}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const results = await Promise.allSettled( + body.orders.map(order => + adapter.shipOrder(order.order_id, { + tracking_number: order.tracking_number, + shipping_provider: order.shipping_provider, + shipping_method: order.shipping_method, + items: order.items, + }).catch(error => ({ + order_id: order.order_id, + success: false, + error: error.message + })) + ) + ); + + const successful = results + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult).value); + + const failed = results + .filter(result => result.status === 'rejected') + .map(result => (result as PromiseRejectedResult).reason); + + this.logger.info(`[Site API] 批量订单发货完成, siteId: ${siteId}, 成功: ${successful.length}, 失败: ${failed.length}`); + return successResponse({ + successful: successful.length, + failed: failed.length, + results: { + successful, + failed + } + }); + } catch (error) { + this.logger.error(`[Site API] 批量订单发货失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/subscriptions') + @ApiOkResponse({ type: UnifiedSubscriptionPaginationDTO }) + async getSubscriptions( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取订阅列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getSubscriptions(query); + this.logger.info(`[Site API] 获取订阅列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个订阅`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取订阅列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/subscriptions/export') + async exportSubscriptions( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const perPage = (query.per_page) || 100; + let page = 1; + const all: any[] = []; + while (true) { + const data = await adapter.getSubscriptions({ ...query, page, per_page: perPage }); + const items = data.items || []; + all.push(...items); + const totalPages = data.totalPages || Math.ceil((data.total || 0) / (data.per_page || perPage)); + if (!items.length || page >= totalPages) break; + page += 1; + } + let items = all; + if (query.ids) { + const ids = new Set(String(query.ids).split(',').map(v => v.trim()).filter(Boolean)); + items = items.filter(i => ids.has(String(i.id))); + } + const header = ['id', 'status', 'customer_id', 'billing_period', 'billing_interval', 'start_date', 'next_payment_date']; + const rows = items.map((s: any) => [s.id, s.status, s.customer_id, s.billing_period, s.billing_interval, s.start_date, s.next_payment_date]); + const csv = [header.join(','), ...rows.map(r => r.map(v => String(v ?? '')).join(','))].join('\n'); + return successResponse({ csv }); + } catch (error) { + return errorResponse(error.message); + } + } + + @Get('/:siteId/media') + @ApiOkResponse({ type: UnifiedMediaPaginationDTO }) + async getMedia( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取媒体列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getMedia(query); + this.logger.info(`[Site API] 获取媒体列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个媒体`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取媒体列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/media/export') + async exportMedia( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const perPage = (query.per_page) || 100; + let page = 1; + const all: any[] = []; + while (true) { + const data = await adapter.getMedia({ ...query, page, per_page: perPage }); + const items = data.items || []; + all.push(...items); + const totalPages = data.totalPages || Math.ceil((data.total || 0) / (data.per_page || perPage)); + if (!items.length || page >= totalPages) break; + page += 1; + } + let items = all; + if (query.ids) { + const ids = new Set(String(query.ids).split(',').map(v => v.trim()).filter(Boolean)); + items = items.filter(i => ids.has(String(i.id))); + } + const header = ['id', 'title', 'media_type', 'mime_type', 'source_url', 'date_created']; + const rows = items.map((m: any) => [m.id, m.title, m.media_type, m.mime_type, m.source_url, m.date_created]); + const csv = [header.join(','), ...rows.map(r => r.map(v => String(v ?? '')).join(','))].join('\n'); + return successResponse({ csv }); + } catch (error) { + return errorResponse(error.message); + } + } + + @Del('/:siteId/media/:id') + @ApiOkResponse({ type: Boolean }) + async deleteMedia( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 删除媒体开始, siteId: ${siteId}, mediaId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const api: any = adapter as any; + if (api.deleteMedia) { + const success = await api.deleteMedia(id); + this.logger.info(`[Site API] 删除媒体成功, siteId: ${siteId}, mediaId: ${id}`); + return successResponse(success); + } + throw new Error('Media delete not supported'); + } catch (error) { + this.logger.error(`[Site API] 删除媒体失败, siteId: ${siteId}, mediaId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Put('/:siteId/media/:id') + @ApiOkResponse({ type: Object }) + async updateMedia( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: any + ) { + this.logger.info(`[Site API] 更新媒体开始, siteId: ${siteId}, mediaId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const api: any = adapter as any; + if (api.updateMedia) { + const res = await api.updateMedia(id, body); + this.logger.info(`[Site API] 更新媒体成功, siteId: ${siteId}, mediaId: ${id}`); + return successResponse(res); + } + throw new Error('Media update not supported'); + } catch (error) { + this.logger.error(`[Site API] 更新媒体失败, siteId: ${siteId}, mediaId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/media/batch') + @ApiOkResponse({ type: Object }) + async batchMedia( + @Param('siteId') siteId: number, + @Body() body: { update?: any[]; delete?: Array } + ) { + this.logger.info(`[Site API] 批量处理媒体开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const updated: any[] = []; + const deleted: Array = []; + const failed: any[] = []; + const api: any = adapter as any; + if (body.update?.length) { + for (const item of body.update) { + try { + if (!api.updateMedia) throw new Error('Media update not supported'); + const res = await api.updateMedia(item.id, item); + updated.push(res); + } catch (e) { + failed.push({ action: 'update', item, error: (e as any).message }); + } + } + } + if (body.delete?.length) { + for (const id of body.delete) { + try { + if (!api.deleteMedia) throw new Error('Media delete not supported'); + const ok = await api.deleteMedia(id); + if (ok) deleted.push(id); + else failed.push({ action: 'delete', id, error: 'delete failed' }); + } catch (e) { + failed.push({ action: 'delete', id, error: (e as any).message }); + } + } + } + this.logger.info(`[Site API] 批量处理媒体完成, siteId: ${siteId}`); + return successResponse({ updated, deleted, failed }); + } catch (error) { + this.logger.error(`[Site API] 批量处理媒体失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/media/convert-webp') + @ApiOkResponse({ type: Object }) + async convertMediaToWebp( + @Param('siteId') siteId: number, + @Body() body: { ids: Array } + ) { + this.logger.info(`[Site API] 批量转换媒体为 webp 开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const api: any = adapter as any; + // 条件判断 如果未提供 ids 列表则抛出错误 + if (!body?.ids || body.ids.length === 0) { + throw new Error('未提供需要转换的媒体ID列表'); + } + if (!api.convertMediaToWebp) { + throw new Error('当前站点不支持媒体转换'); + } + const res = await api.convertMediaToWebp(body.ids); + this.logger.info(`[Site API] 批量转换媒体为 webp 成功, siteId: ${siteId}`); + return successResponse(res); + } catch (error) { + this.logger.error(`[Site API] 批量转换媒体为 webp 失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/customers') + @ApiOkResponse({ type: UnifiedCustomerPaginationDTO }) + async getCustomers( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + this.logger.info(`[Site API] 获取客户列表开始, siteId: ${siteId}, query: ${JSON.stringify(query)}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getCustomers(query); + this.logger.info(`[Site API] 获取客户列表成功, siteId: ${siteId}, 共获取到 ${data.total} 个客户`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取客户列表失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Get('/:siteId/customers/export') + async exportCustomers( + @Param('siteId') siteId: number, + @Query() query: UnifiedSearchParamsDTO + ) { + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const perPage = (query.per_page) || 100; + let page = 1; + const all: any[] = []; + while (true) { + const data = await adapter.getCustomers({ ...query, page, per_page: perPage}); + const items = data.items || []; + all.push(...items); + const totalPages = data.totalPages || Math.ceil((data.total || 0) / (data.per_page || perPage)); + if (!items.length || page >= totalPages) break; + page += 1; + } + let items = all; + if (query.ids) { + const ids = new Set(String(query.ids).split(',').map(v => v.trim()).filter(Boolean)); + items = items.filter(i => ids.has(String(i.id))); + } + const header = ['id', 'email', 'first_name', 'last_name', 'fullname', 'username', 'phone', 'orders', 'total_spend', 'role', 'billing_full_address', 'shipping_full_address', 'date_created']; + const formatAddress = (addr: any) => [ + addr?.fullname, + addr?.company, + addr?.address_1, + addr?.address_2, + addr?.city, + addr?.state, + addr?.postcode, + addr?.country, + addr?.phone, + ].filter(Boolean).join(', '); + const rows = items.map((c: any) => [ + c.id, + c.email, + c.first_name, + c.last_name, + c.fullname, + (c.username || c.raw?.username || ''), + (c.phone || c.billing?.phone || c.shipping?.phone || ''), + c.orders, + c.total_spend, + (c.role || c.raw?.role || ''), + formatAddress(c.billing || {}), + formatAddress(c.shipping || {}), + c.date_created, + ]); + const csv = [header.join(','), ...rows.map(r => r.map(v => String(v ?? '')).join(','))].join('\n'); + return successResponse({ csv }); + } catch (error) { + return errorResponse(error.message); + } + } + + @Get('/:siteId/customers/:id') + @ApiOkResponse({ type: UnifiedCustomerDTO }) + async getCustomer( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 获取单个客户开始, siteId: ${siteId}, customerId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.getCustomer(id); + this.logger.info(`[Site API] 获取单个客户成功, siteId: ${siteId}, customerId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 获取单个客户失败, siteId: ${siteId}, customerId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/customers') + @ApiOkResponse({ type: UnifiedCustomerDTO }) + async createCustomer( + @Param('siteId') siteId: number, + @Body() body: UnifiedCustomerDTO + ) { + this.logger.info(`[Site API] 创建客户开始, siteId: ${siteId}, 客户邮箱: ${body.email}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.createCustomer(body); + this.logger.info(`[Site API] 创建客户成功, siteId: ${siteId}, customerId: ${data.id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 创建客户失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/customers/import') + @ApiOkResponse({ type: Object }) + async importCustomers( + @Param('siteId') siteId: number, + @Body() body: { items?: any[]; csv?: string } + ) { + try { + const adapter = await this.siteApiService.getAdapter(siteId); + let items = body.items || []; + if (!items.length && body.csv) { + const lines = body.csv.split(/\r?\n/).filter(Boolean); + const header = lines.shift()?.split(',') || []; + items = lines.map((line) => { + const cols = line.split(','); + const obj: any = {}; + header.forEach((h, i) => (obj[h] = cols[i])); + return obj; + }); + } + const created: any[] = []; + const failed: any[] = []; + for (const item of items) { + try { + const data = await adapter.createCustomer(item); + created.push(data); + } catch (e) { + failed.push({ item, error: (e as any).message }); + } + } + return successResponse({ created, failed }); + } catch (error) { + return errorResponse(error.message); + } + } + + @Put('/:siteId/customers/:id') + @ApiOkResponse({ type: UnifiedCustomerDTO }) + async updateCustomer( + @Param('siteId') siteId: number, + @Param('id') id: string, + @Body() body: UnifiedCustomerDTO + ) { + this.logger.info(`[Site API] 更新客户开始, siteId: ${siteId}, customerId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const data = await adapter.updateCustomer(id, body); + this.logger.info(`[Site API] 更新客户成功, siteId: ${siteId}, customerId: ${id}`); + return successResponse(data); + } catch (error) { + this.logger.error(`[Site API] 更新客户失败, siteId: ${siteId}, customerId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Del('/:siteId/customers/:id') + @ApiOkResponse({ type: Boolean }) + async deleteCustomer( + @Param('siteId') siteId: number, + @Param('id') id: string + ) { + this.logger.info(`[Site API] 删除客户开始, siteId: ${siteId}, customerId: ${id}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const success = await adapter.deleteCustomer(id); + this.logger.info(`[Site API] 删除客户成功, siteId: ${siteId}, customerId: ${id}`); + return successResponse(success); + } catch (error) { + this.logger.error(`[Site API] 删除客户失败, siteId: ${siteId}, customerId: ${id}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + @Post('/:siteId/customers/batch') + @ApiOkResponse({ type: Object }) + async batchCustomers( + @Param('siteId') siteId: number, + @Body() body: { create?: any[]; update?: any[]; delete?: Array } + ) { + this.logger.info(`[Site API] 批量处理客户开始, siteId: ${siteId}`); + try { + const adapter = await this.siteApiService.getAdapter(siteId); + const created: any[] = []; + const updated: any[] = []; + const deleted: Array = []; + const failed: any[] = []; + if (body.create?.length) { + for (const item of body.create) { + try { + const data = await adapter.createCustomer(item); + created.push(data); + } catch (e) { + failed.push({ action: 'create', item, error: (e as any).message }); + } + } + } + if (body.update?.length) { + for (const item of body.update) { + try { + const id = item.id; + const data = await adapter.updateCustomer(id, item); + updated.push(data); + } catch (e) { + failed.push({ action: 'update', item, error: (e as any).message }); + } + } + } + if (body.delete?.length) { + for (const id of body.delete) { + try { + const ok = await adapter.deleteCustomer(id); + if (ok) deleted.push(id); + else failed.push({ action: 'delete', id, error: 'delete failed' }); + } catch (e) { + failed.push({ action: 'delete', id, error: (e as any).message }); + } + } + } + this.logger.info(`[Site API] 批量处理客户完成, siteId: ${siteId}`); + return successResponse({ created, updated, deleted, failed }); + } catch (error) { + this.logger.error(`[Site API] 批量处理客户失败, siteId: ${siteId}, 错误信息: ${error.message}`); + return errorResponse(error.message); + } + } + + +} diff --git a/src/controller/site.controller.ts b/src/controller/site.controller.ts index 5c848d5..02cf312 100644 --- a/src/controller/site.controller.ts +++ b/src/controller/site.controller.ts @@ -15,7 +15,7 @@ export class SiteController { async all() { try { const { items } = await this.siteService.list({ current: 1, pageSize: 1000, isDisabled: false }); - return successResponse(items.map((v: any) => ({ id: v.id, siteName: v.siteName }))); + return successResponse(items.map((v: any) => ({ id: v.id, name: v.name }))); } catch (error) { return errorResponse(error?.message || '获取失败'); } diff --git a/src/controller/stock.controller.ts b/src/controller/stock.controller.ts index a407df0..1728bda 100644 --- a/src/controller/stock.controller.ts +++ b/src/controller/stock.controller.ts @@ -176,9 +176,21 @@ export class StockController { } } + // 检查某个 SKU 是否有库存(任一仓库数量大于 0) + @ApiOkResponse({ type: BooleanRes }) + @Get('/has/:sku') + async hasStock(@Param('sku') sku: string) { + try { + const data = await this.stockService.hasStockBySku(sku); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || '查询失败'); + } + } + @ApiOkResponse({ type: BooleanRes, - description: '更新库存(入库、出库、调整)', + description: '更新库存(入库,出库,调整)', }) @Post('/update') async updateStock(@Body() body: UpdateStockDTO) { diff --git a/src/controller/subscription.controller.ts b/src/controller/subscription.controller.ts index 5506063..61059e3 100644 --- a/src/controller/subscription.controller.ts +++ b/src/controller/subscription.controller.ts @@ -10,19 +10,19 @@ export class SubscriptionController { @Inject() subscriptionService: SubscriptionService; - // 同步订阅:根据站点 ID 拉取并更新本地订阅数据 + // 同步订阅:根据站点 ID 拉取并更新本地订阅数据 @ApiOkResponse({ type: BooleanRes }) @Post('/sync/:siteId') - async sync(@Param('siteId') siteId: string) { + async sync(@Param('siteId') siteId: number) { try { - await this.subscriptionService.syncSubscriptions(siteId); - return successResponse(true); + const result = await this.subscriptionService.syncSubscriptions(siteId); + return successResponse(result); } catch (error) { return errorResponse(error?.message || '同步失败'); } } - // 订阅列表:分页 + 筛选 + // 订阅列表:分页 + 筛选 @ApiOkResponse({ type: SubscriptionListRes }) @Get('/list') async list(@Query() query: QuerySubscriptionDTO) { diff --git a/src/controller/template.controller.ts b/src/controller/template.controller.ts new file mode 100644 index 0000000..330ce2b --- /dev/null +++ b/src/controller/template.controller.ts @@ -0,0 +1,168 @@ +import { Inject, Controller, Get, Post, Put, Del, Body, Param, Query } from '@midwayjs/core'; +import { TemplateService } from '../service/template.service'; +import { successResponse, errorResponse } from '../utils/response.util'; +import { CreateTemplateDTO, UpdateTemplateDTO, RenderTemplateDTO } from '../dto/template.dto'; +import { ApiOkResponse, ApiTags } from '@midwayjs/swagger'; +import { Template } from '../entity/template.entity'; +import { BooleanRes } from '../dto/reponse.dto'; + +/** + * @controller TemplateController 模板管理 + */ +@ApiTags('Template') +@Controller('/template') +export class TemplateController { + @Inject() + templateService: TemplateService; + + /** + * @summary 获取模板列表 + * @description 获取所有可用模板的列表 + */ + @ApiOkResponse({ type: [Template], description: '成功获取模板列表' }) + @Get('/list') + async getTemplateList(@Query() params: any) { + // 调用服务层获取列表 + return this.templateService.getTemplateList(params); + } + + /** + * @summary 根据名称获取模板 + * @description 通过模板的唯一名称查找特定模板 + * @param name 模板名称 + */ + @ApiOkResponse({ type: Template, description: '成功获取模板' }) + @Get('/:name') + async getTemplateByName(@Param('name') name: string) { + try { + // 调用服务层获取单个模板 + const data = await this.templateService.getTemplateByName(name); + // 返回成功响应 + return successResponse(data); + } catch (error) { + // 返回错误响应 + return errorResponse(error.message); + } + } + + /** + * @summary 创建新模板 + * @description 创建一个新的模板,用于后续的字符串生成 + * @param templateData 模板数据 + */ + @ApiOkResponse({ type: Template, description: '成功创建模板' }) + @Post('/') + async createTemplate(@Body() templateData: CreateTemplateDTO) { + try { + // 调用服务层创建模板 + const data = await this.templateService.createTemplate(templateData); + // 返回成功响应 + return successResponse(data); + } catch (error) { + // 返回错误响应 + return errorResponse(error.message); + } + } + + /** + * @summary 更新现有模板 + * @description 根据模板 ID 更新一个现有模板的内容 + * @param id 模板 ID + * @param templateData 模板数据 + */ + @ApiOkResponse({ type: Template, description: '成功更新模板' }) + @Put('/:id') + async updateTemplate( + @Param('id') id: number, + @Body() templateData: UpdateTemplateDTO + ) { + try { + // 调用服务层更新模板 + const data = await this.templateService.updateTemplate(id, templateData); + // 返回成功响应 + return successResponse(data); + } catch (error) { + // 返回错误响应 + return errorResponse(error.message); + } + } + + /** + * @summary 删除模板 + * @description 根据模板 ID 删除一个模板 + * @param id 模板 ID + */ + @ApiOkResponse({ type: BooleanRes, description: '成功删除模板' }) + @Del('/:id') + async deleteTemplate(@Param('id') id: number) { + try { + // 调用服务层删除模板 + const data = await this.templateService.deleteTemplate(id); + // 返回成功响应 + return successResponse(data); + } catch (error) { + // 返回错误响应 + return errorResponse(error.message); + } + } + + /** + * @summary 渲染模板 + * @description 根据模板名称和输入的数据渲染最终字符串 + * @param name 模板名称 + * @param data 渲染数据 + */ + @ApiOkResponse({ type: String, description: '成功渲染模板' }) + @Post('/render/:name') + async renderTemplate( + @Param('name') name: string, + @Body() data: Record + ) { + try { + // 调用服务层渲染模板 + const renderedString = await this.templateService.render(name, data); + // 返回成功响应 + return successResponse(renderedString); + } catch (error) { + // 返回错误响应 + return errorResponse(error.message); + } + } + + /** + * @summary 回填缺失的测试数据 + * @description 扫描数据库中所有模板,为缺失 testData 的记录生成并保存测试数据 + */ + @ApiOkResponse({ type: Number, description: '成功回填的数量' }) + @Post('/backfill-testdata') + async backfillTestData() { + try { + const count = await this.templateService.backfillMissingTestData(); + return successResponse({ updated: count }); + } catch (error) { + return errorResponse(error.message); + } + } + + /** + * @summary 直接渲染模板内容 + * @description 直接传入模板内容和数据渲染最终字符串,无需保存模板到数据库 + * @param renderData 包含模板内容和渲染数据的对象 + */ + @ApiOkResponse({ type: String, description: '成功渲染模板' }) + @Post('/render-direct') + async renderTemplateDirect(@Body() renderData: RenderTemplateDTO) { + try { + // 调用服务层渲染模板内容 + const renderedString = await this.templateService.renderWithTemplate( + renderData.template, + renderData.data + ); + // 返回成功响应 + return successResponse(renderedString); + } catch (error) { + // 返回错误响应 + return errorResponse(error.message); + } + } +} diff --git a/src/controller/user.controller.ts b/src/controller/user.controller.ts index 662c253..711f1f2 100644 --- a/src/controller/user.controller.ts +++ b/src/controller/user.controller.ts @@ -34,16 +34,17 @@ export class UserController { }) @Post('/logout') async logout() { - // 可选:在这里处理服务端缓存的 token 或 session + // 可选:在这里处理服务端缓存的 token 或 session return successResponse(true); } @Post('/add') - async addUser(@Body() body: { username: string; password: string }) { - const { username, password } = body; + async addUser(@Body() body: { username: string; password: string; email?: string; remark?: string }) { + const { username, password, email, remark } = body; try { - await this.userService.addUser(username, password); + // 新增用户 支持邮箱与备注 + await this.userService.addUser(username, password, remark, email); return successResponse(true); } catch (error) { console.log(error); @@ -52,21 +53,91 @@ export class UserController { } @Get('/list') - async listUsers(@Query() query: { current: number; pageSize: number }) { - const { current = 1, pageSize = 10 } = query; - return successResponse(await this.userService.listUsers(current, pageSize)); + async listUsers( + @Query() + query: { + current: number; + pageSize: number; + remark?: string; + username?: string; + email?: string; + isActive?: string; + isSuper?: string; + isAdmin?: string; + sortField?: string; + sortOrder?: string; + } + ) { + const { current = 1, pageSize = 10, remark, username, email, isActive, isSuper, isAdmin, sortField, sortOrder } = query; + // 将字符串布尔转换为真实布尔 + const toBool = (v?: string) => (v === undefined ? undefined : v === 'true'); + // 处理排序方向 + const order = (sortOrder === 'ascend' || sortOrder === 'ASC') ? 'ASC' : 'DESC'; + + // 列表移除密码字段 + const { items, total } = await this.userService.listUsers( + current, + pageSize, + { + remark, + username, + email, + isActive: toBool(isActive), + isSuper: toBool(isSuper), + isAdmin: toBool(isAdmin), + }, + { + field: sortField, + order, + } + ); + const safeItems = (items || []).map((it: any) => { + const { password, ...rest } = it || {}; + return rest; + }); + return successResponse({ items: safeItems, total, current, pageSize }); } @Post('/toggleActive') async toggleActive(@Body() body: { userId: number; isActive: boolean }) { - return this.userService.toggleUserActive(body.userId, body.isActive); + try { + // 调用服务层更新启用状态 + const data = await this.userService.toggleUserActive(body.userId, body.isActive); + // 移除密码字段,保证安全 + const { password, ...safe } = data as any; + return successResponse(safe); + } catch (error) { + return errorResponse(error?.message || '操作失败'); + } + } + + // 更新用户(支持用户名/密码/权限/角色更新) + @Post('/update/:id') + async updateUser( + @Body() body: { username?: string; password?: string; email?: string; isSuper?: boolean; isAdmin?: boolean; permissions?: string[]; remark?: string }, + @Query('id') id?: number + ) { + try { + // 条件判断:优先从路径参数获取 ID(兼容生成的 API 文件为 POST /user/update/:id) + const userId = Number((this.ctx?.params?.id ?? id)); + if (!userId) throw new Error('缺少用户ID'); + const data = await this.userService.updateUser(userId, body); + // 移除密码字段,保证安全 + const { password, ...safe } = data as any; + return successResponse(safe); + } catch (error) { + return errorResponse(error?.message || '更新失败'); + } } @ApiOkResponse() @Get() async getUser(@User() user) { try { - return successResponse(await this.userService.getUser(user.id)); + // 详情移除密码字段 + const data = await this.userService.getUser(user.id); + const { password, ...safe } = (data as any) || {}; + return successResponse(safe); } catch (error) { return errorResponse('获取失败'); } diff --git a/src/controller/webhook.controller.ts b/src/controller/webhook.controller.ts index d15da14..c4398b0 100644 --- a/src/controller/webhook.controller.ts +++ b/src/controller/webhook.controller.ts @@ -9,8 +9,7 @@ import { } from '@midwayjs/decorator'; import { Context } from '@midwayjs/koa'; import * as crypto from 'crypto'; -import { WpProductService } from '../service/wp_product.service'; -import { WPService } from '../service/wp.service'; + import { SiteService } from '../service/site.service'; import { OrderService } from '../service/order.service'; @@ -18,11 +17,7 @@ import { OrderService } from '../service/order.service'; export class WebhookController { private secret = 'YOONE24kd$kjcdjflddd'; - @Inject() - private readonly wpProductService: WpProductService; - - @Inject() - private readonly wpApiService: WPService; + // 平台服务保留按需注入 @Inject() private readonly orderService: OrderService; @@ -33,7 +28,7 @@ export class WebhookController { @Inject() private readonly siteService: SiteService; - // 移除配置中的站点数组,来源统一改为数据库 + // 移除配置中的站点数组,来源统一改为数据库 @Get('/') async test() { @@ -43,14 +38,15 @@ export class WebhookController { @Post('/woocommerce') async handleWooWebhook( @Body() body: any, - @Query('siteId') siteId: string, + @Query('siteId') siteIdStr: string, @Headers() header: any ) { const signature = header['x-wc-webhook-signature']; const topic = header['x-wc-webhook-topic']; const source = header['x-wc-webhook-source']; + const siteId = Number(siteIdStr); // 从数据库获取站点配置 - const site = await this.siteService.get(Number(siteId), true); + const site = await this.siteService.get(siteId, true); if (!site || !source.includes(site.apiUrl)) { console.log('domain not match'); @@ -78,32 +74,10 @@ export class WebhookController { switch (topic) { case 'product.created': case 'product.updated': - // 变体更新 - if (body.type === 'variation') { - const variation = await this.wpApiService.getVariation( - site, - body.parent_id, - body.id - ); - this.wpProductService.syncVariation( - siteId, - body.parent_id, - variation - ); - break; - } - const variations = - body.type === 'variable' - ? await this.wpApiService.getVariations(site, body.id) - : []; - await this.wpProductService.syncProductAndVariations( - String(site.id), - body, - variations - ); + // 不再写入本地,平台事件仅确认接收 break; case 'product.deleted': - await this.wpProductService.delWpProduct(String(site.id), body.id); + // 不再写入本地,平台事件仅确认接收 break; case 'order.created': case 'order.updated': diff --git a/src/controller/wp_product.controller.ts b/src/controller/wp_product.controller.ts index 8623b1d..327c6a2 100644 --- a/src/controller/wp_product.controller.ts +++ b/src/controller/wp_product.controller.ts @@ -7,6 +7,8 @@ import { Query, Put, Body, + Files, + Del, } from '@midwayjs/core'; import { WpProductService } from '../service/wp_product.service'; import { errorResponse, successResponse } from '../utils/response.util'; @@ -14,71 +16,125 @@ import { ApiOkResponse } from '@midwayjs/swagger'; import { BooleanRes, WpProductListRes } from '../dto/reponse.dto'; import { QueryWpProductDTO, - SetConstitutionDTO, UpdateVariationDTO, UpdateWpProductDTO, + BatchSyncProductsDTO, + BatchUpdateTagsDTO, + BatchUpdateProductsDTO, } from '../dto/wp_product.dto'; -import { WPService } from '../service/wp.service'; -import { SiteService } from '../service/site.service'; + import { ProductsRes, } from '../dto/reponse.dto'; @Controller('/wp_product') export class WpProductController { - // 移除控制器内的配置站点引用,统一由服务层处理站点数据 + // 移除控制器内的配置站点引用,统一由服务层处理站点数据 @Inject() private readonly wpProductService: WpProductService; - @Inject() - private readonly wpApiService: WPService; + // 平台服务保留按需注入 - @Inject() - private readonly siteService: SiteService; + @ApiOkResponse({ + type: BooleanRes, + }) + @Del('/:id') + async delete(@Param('id') id: number) { + return errorResponse('接口已废弃,请改用 /site-api/:siteId/products 删除'); + } + + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/import/:siteId') + async importProducts(@Param('siteId') siteId: number, @Files() files) { + try { + if (!files || files.length === 0) { + throw new Error('请上传文件'); + } + await this.wpProductService.importProducts(siteId, files[0]); + return successResponse(true); + } catch (error) { + console.error('导入失败:', error); + return errorResponse(error.message || '导入失败'); + } + } + + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/setconstitution') + async setConstitution(@Body() body: any) { + try { + return successResponse(true); + } catch (error) { + return errorResponse(error.message || '设置失败'); + } + } + + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/batch-update') + async batchUpdateProducts(@Body() body: BatchUpdateProductsDTO) { + try { + await this.wpProductService.batchUpdateProducts(body); + return successResponse(true); + } catch (error) { + return errorResponse(error.message || '批量更新失败'); + } + } + + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/batch-update-tags') + async batchUpdateTags(@Body() body: BatchUpdateTagsDTO) { + try { + await this.wpProductService.batchUpdateTags(body.ids, body.tags); + return successResponse(true); + } catch (error) { + return errorResponse(error.message || '批量更新标签失败'); + } + } @ApiOkResponse({ type: BooleanRes, }) @Post('/sync/:siteId') - async syncProducts(@Param('siteId') siteId: string) { + async syncProducts(@Param('siteId') siteId: number) { try { - await this.wpProductService.syncSite(siteId); - return successResponse(true); + const result = await this.wpProductService.syncSite(siteId); + return successResponse(result); } catch (error) { console.log(error); return errorResponse('同步失败'); } } + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/batch-sync-to-site/:siteId') + async batchSyncToSite( + @Param('siteId') siteId: number, + @Body() body: BatchSyncProductsDTO + ) { + try { + await this.wpProductService.batchSyncToSite(siteId, body.productIds); + return successResponse(true, '批量同步成功'); + } catch (error) { + console.error('批量同步失败:', error); + return errorResponse(error.message || '批量同步失败'); + } + } + @ApiOkResponse({ type: WpProductListRes, }) @Get('/list') async getWpProducts(@Query() query: QueryWpProductDTO) { - try { - const data = await this.wpProductService.getProductList(query); - return successResponse(data); - } catch (error) { - return errorResponse(error.message); - } - } - - @ApiOkResponse({ - type: BooleanRes, - }) - @Put('/:id/constitution') - async setConstitution( - @Param('id') id: number, - @Body() - body: SetConstitutionDTO - ) { - const { isProduct, constitution } = body; - try { - await this.wpProductService.setConstitution(id, isProduct, constitution); - return successResponse(true); - } catch (error) { - return errorResponse(error.message); - } + return errorResponse('接口已废弃,请改用 /site-api/:siteId/products 列表'); } @ApiOkResponse({ @@ -97,6 +153,22 @@ export class WpProductController { } } + /** + * 创建产品接口 + * @param siteId 站点 ID + * @param body 创建数据 + */ + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/siteId/:siteId/products') + async createProduct( + @Param('siteId') siteId: number, + @Body() body: any + ) { + return errorResponse('接口已废弃,请改用 /site-api/:siteId/products 创建'); + } + /** * 更新产品接口 * @param productId 产品 ID @@ -107,33 +179,23 @@ export class WpProductController { }) @Put('/siteId/:siteId/products/:productId') async updateProduct( - @Param('siteId') siteId: string, + @Param('siteId') siteId: number, @Param('productId') productId: string, @Body() body: UpdateWpProductDTO ) { + return errorResponse('接口已废弃,请改用 /site-api/:siteId/products/:id 更新'); + } + + @ApiOkResponse({ + type: BooleanRes, + }) + @Post('/sync-to-product/:id') + async syncToProduct(@Param('id') id: number) { try { - const isDuplicate = await this.wpProductService.isSkuDuplicate( - body.sku, - siteId, - productId - ); - if (isDuplicate) { - return errorResponse('SKU已存在'); - } - const site = await this.siteService.get(Number(siteId), true); - const result = await this.wpApiService.updateProduct( - site, - productId, - body - ); - if (result) { - this.wpProductService.updateWpProduct(siteId, productId, body); - return successResponse(result, '产品更新成功'); - } - return errorResponse('产品更新失败'); + await this.wpProductService.syncToProduct(id); + return successResponse(true); } catch (error) { - console.error('更新产品失败:', error); - return errorResponse(error.message || '产品更新失败'); + return errorResponse(error.message); } } @@ -145,42 +207,12 @@ export class WpProductController { */ @Put('/siteId/:siteId/products/:productId/variations/:variationId') async updateVariation( - @Param('siteId') siteId: string, + @Param('siteId') siteId: number, @Param('productId') productId: string, @Param('variationId') variationId: string, @Body() body: UpdateVariationDTO ) { - try { - const isDuplicate = await this.wpProductService.isSkuDuplicate( - body.sku, - siteId, - productId, - variationId - ); - if (isDuplicate) { - return errorResponse('SKU已存在'); - } - const site = await this.siteService.get(Number(siteId), true); - const result = await this.wpApiService.updateVariation( - site, - productId, - variationId, - body - ); - if (result) { - this.wpProductService.updateWpProductVaritation( - siteId, - productId, - variationId, - body - ); - return successResponse(result, '产品变体更新成功'); - } - return errorResponse('变体更新失败'); - } catch (error) { - console.error('更新变体失败:', error); - return errorResponse(error.message || '产品变体更新失败'); - } + return errorResponse('接口已废弃,请改用 /site-api/:siteId/products/:productId/variations/:variationId 更新'); } @ApiOkResponse({ diff --git a/src/db/datasource.ts b/src/db/datasource.ts new file mode 100644 index 0000000..8dcfd63 --- /dev/null +++ b/src/db/datasource.ts @@ -0,0 +1,20 @@ +import { DataSource, DataSourceOptions } from 'typeorm'; +import { SeederOptions } from 'typeorm-extension'; + + +const options: DataSourceOptions & SeederOptions = { + type: 'mysql', + host: '127.0.0.1', + port: 23306, + username: 'root', + password: '12345678', + database: 'inventory', + synchronize: true, + logging: true, + entities: [__dirname + '/../entity/*.ts'], + migrations: ['src/db/migrations/**/*.ts'], + seeds: ['src/db/seeds/**/*.ts'], +}; + +export const AppDataSource = new DataSource(options); + diff --git a/src/db/migrations/1764238434984-product-dict-item-many-to-many.ts b/src/db/migrations/1764238434984-product-dict-item-many-to-many.ts new file mode 100644 index 0000000..1011b18 --- /dev/null +++ b/src/db/migrations/1764238434984-product-dict-item-many-to-many.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ProductDictItemManyToMany1764238434984 implements MigrationInterface { + name = 'ProductDictItemManyToMany1764238434984' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE \`product_attributes_dict_item\` (\`productId\` int NOT NULL, \`dictItemId\` int NOT NULL, INDEX \`IDX_592cdbdaebfec346c202ffb82c\` (\`productId\`), INDEX \`IDX_406c1da5b6de45fecb7967c3ec\` (\`dictItemId\`), PRIMARY KEY (\`productId\`, \`dictItemId\`)) ENGINE=InnoDB`); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`brandId\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`flavorsId\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`strengthId\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`humidity\``); + await queryRunner.query(`ALTER TABLE \`product\` ADD \`sku\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`product\` ADD UNIQUE INDEX \`IDX_34f6ca1cd897cc926bdcca1ca3\` (\`sku\`)`); + await queryRunner.query(`ALTER TABLE \`product_attributes_dict_item\` ADD CONSTRAINT \`FK_592cdbdaebfec346c202ffb82ca\` FOREIGN KEY (\`productId\`) REFERENCES \`product\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`product_attributes_dict_item\` ADD CONSTRAINT \`FK_406c1da5b6de45fecb7967c3ec0\` FOREIGN KEY (\`dictItemId\`) REFERENCES \`dict_item\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`product_attributes_dict_item\` DROP FOREIGN KEY \`FK_406c1da5b6de45fecb7967c3ec0\``); + await queryRunner.query(`ALTER TABLE \`product_attributes_dict_item\` DROP FOREIGN KEY \`FK_592cdbdaebfec346c202ffb82ca\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP INDEX \`IDX_34f6ca1cd897cc926bdcca1ca3\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`sku\``); + await queryRunner.query(`ALTER TABLE \`product\` ADD \`humidity\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`product\` ADD \`strengthId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`product\` ADD \`flavorsId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`product\` ADD \`brandId\` int NOT NULL`); + await queryRunner.query(`DROP INDEX \`IDX_406c1da5b6de45fecb7967c3ec\` ON \`product_attributes_dict_item\``); + await queryRunner.query(`DROP INDEX \`IDX_592cdbdaebfec346c202ffb82c\` ON \`product_attributes_dict_item\``); + await queryRunner.query(`DROP TABLE \`product_attributes_dict_item\``); + } + +} diff --git a/src/db/migrations/1764294088896-Area.ts b/src/db/migrations/1764294088896-Area.ts new file mode 100644 index 0000000..e2948bb --- /dev/null +++ b/src/db/migrations/1764294088896-Area.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Area1764294088896 implements MigrationInterface { + name = 'Area1764294088896' + + public async up(queryRunner: QueryRunner): Promise { + // await queryRunner.query(`DROP INDEX \`IDX_4ca3fbc46d2dbf393ff4ebddbb\` ON \`site\``); + // await queryRunner.query(`CREATE TABLE \`area\` (\`id\` int NOT NULL AUTO_INCREMENT, \`name\` varchar(255) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_644ffaf8fbde4db798cb47712f\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + // await queryRunner.query(`CREATE TABLE \`stock_point_areas_area\` (\`stockPointId\` int NOT NULL, \`areaId\` int NOT NULL, INDEX \`IDX_07d2db2150151e2ef341d2f1de\` (\`stockPointId\`), INDEX \`IDX_92707ea81fc19dc707dba24819\` (\`areaId\`), PRIMARY KEY (\`stockPointId\`, \`areaId\`)) ENGINE=InnoDB`); + // await queryRunner.query(`CREATE TABLE \`site_areas_area\` (\`siteId\` int NOT NULL, \`areaId\` int NOT NULL, INDEX \`IDX_926a14ac4c91f38792831acd2a\` (\`siteId\`), INDEX \`IDX_7c26c582048e3ecd3cd5938cb9\` (\`areaId\`), PRIMARY KEY (\`siteId\`, \`areaId\`)) ENGINE=InnoDB`); + // await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`name\``); + // await queryRunner.query(`ALTER TABLE `product` ADD `promotionPrice` decimal(10,2) NOT NULL DEFAULT '0.00'`); + // await queryRunner.query(`ALTER TABLE `product` ADD `source` int NOT NULL DEFAULT '0'`); + // await queryRunner.query(`ALTER TABLE \`site\` ADD \`token\` varchar(255) NULL`); + // await queryRunner.query(`ALTER TABLE `site` ADD `name` varchar(255) NOT NULL`); + // await queryRunner.query(`ALTER TABLE \`site\` ADD UNIQUE INDEX \`IDX_9669a09fcc0eb6d2794a658f64\` (\`name\`)`); + await queryRunner.query(`ALTER TABLE \`stock_point_areas_area\` ADD CONSTRAINT \`FK_07d2db2150151e2ef341d2f1de1\` FOREIGN KEY (\`stockPointId\`) REFERENCES \`stock_point\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`stock_point_areas_area\` ADD CONSTRAINT \`FK_92707ea81fc19dc707dba24819c\` FOREIGN KEY (\`areaId\`) REFERENCES \`area\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`site_areas_area\` ADD CONSTRAINT \`FK_926a14ac4c91f38792831acd2a6\` FOREIGN KEY (\`siteId\`) REFERENCES \`site\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`site_areas_area\` ADD CONSTRAINT \`FK_7c26c582048e3ecd3cd5938cb9f\` FOREIGN KEY (\`areaId\`) REFERENCES \`area\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`site_areas_area\` DROP FOREIGN KEY \`FK_7c26c582048e3ecd3cd5938cb9f\``); + await queryRunner.query(`ALTER TABLE \`site_areas_area\` DROP FOREIGN KEY \`FK_926a14ac4c91f38792831acd2a6\``); + await queryRunner.query(`ALTER TABLE \`stock_point_areas_area\` DROP FOREIGN KEY \`FK_92707ea81fc19dc707dba24819c\``); + await queryRunner.query(`ALTER TABLE \`stock_point_areas_area\` DROP FOREIGN KEY \`FK_07d2db2150151e2ef341d2f1de1\``); + await queryRunner.query(`ALTER TABLE \`site\` DROP INDEX \`IDX_9669a09fcc0eb6d2794a658f64\``); + await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`name\``); + await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`token\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`source\``); + await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`promotionPrice\``); + await queryRunner.query(`ALTER TABLE \`site\` ADD \`name\` varchar(255) NOT NULL`); + await queryRunner.query(`DROP INDEX \`IDX_7c26c582048e3ecd3cd5938cb9\` ON \`site_areas_area\``); + await queryRunner.query(`DROP INDEX \`IDX_926a14ac4c91f38792831acd2a\` ON \`site_areas_area\``); + await queryRunner.query(`DROP TABLE \`site_areas_area\``); + await queryRunner.query(`DROP INDEX \`IDX_92707ea81fc19dc707dba24819\` ON \`stock_point_areas_area\``); + await queryRunner.query(`DROP INDEX \`IDX_07d2db2150151e2ef341d2f1de\` ON \`stock_point_areas_area\``); + await queryRunner.query(`DROP TABLE \`stock_point_areas_area\``); + await queryRunner.query(`DROP INDEX \`IDX_644ffaf8fbde4db798cb47712f\` ON \`area\``); + await queryRunner.query(`DROP TABLE \`area\``); + await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_4ca3fbc46d2dbf393ff4ebddbb\` ON \`site\` (\`name\`)`); + } + +} diff --git a/src/db/migrations/1764299629279-ProductStock.ts b/src/db/migrations/1764299629279-ProductStock.ts new file mode 100644 index 0000000..9767e45 --- /dev/null +++ b/src/db/migrations/1764299629279-ProductStock.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ProductStock1764299629279 implements MigrationInterface { + name = 'ProductStock1764299629279' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE \`order_item_original\` (\`id\` int NOT NULL AUTO_INCREMENT, \`order_id\` int NOT NULL, \`name\` varchar(255) NOT NULL, \`siteId\` varchar(255) NOT NULL, \`externalOrderId\` varchar(255) NOT NULL, \`externalOrderItemId\` varchar(255) NULL, \`externalProductId\` varchar(255) NOT NULL, \`externalVariationId\` varchar(255) NOT NULL, \`quantity\` int NOT NULL, \`subtotal\` decimal(10,2) NULL, \`subtotal_tax\` decimal(10,2) NULL, \`total\` decimal(10,2) NULL, \`total_tax\` decimal(10,2) NULL, \`sku\` varchar(255) NULL, \`price\` decimal(10,2) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD CONSTRAINT \`FK_ca48e4bce0bb8cecd24cc8081e5\` FOREIGN KEY (\`order_id\`) REFERENCES \`order\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP FOREIGN KEY \`FK_ca48e4bce0bb8cecd24cc8081e5\``); + await queryRunner.query(`DROP TABLE \`order_item_original\``); + } + +} diff --git a/src/db/migrations/1764569947170-update-dict-item-unique-constraint.ts b/src/db/migrations/1764569947170-update-dict-item-unique-constraint.ts new file mode 100644 index 0000000..e71f676 --- /dev/null +++ b/src/db/migrations/1764569947170-update-dict-item-unique-constraint.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdateDictItemUniqueConstraint1764569947170 implements MigrationInterface { + name = 'UpdateDictItemUniqueConstraint1764569947170' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalOrderId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalProductId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalVariationId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`price\` decimal(10,2) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`price\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalVariationId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalProductId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalOrderId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + } + +} diff --git a/src/db/migrations/1765275715762-add_test_data_to_template.ts b/src/db/migrations/1765275715762-add_test_data_to_template.ts new file mode 100644 index 0000000..24956b6 --- /dev/null +++ b/src/db/migrations/1765275715762-add_test_data_to_template.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTestDataToTemplate1765275715762 implements MigrationInterface { + name = 'AddTestDataToTemplate1765275715762' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`site_stock_points_stock_point\` DROP FOREIGN KEY \`FK_e93d8c42c9baf5a0dade42c59ae\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalOrderId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalProductId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalVariationId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`price\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total_tax\``); + await queryRunner.query(`ALTER TABLE \`template\` ADD \`testData\` text NULL COMMENT '测试数据JSON'`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalOrderId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalProductId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalVariationId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`price\` decimal(10,2) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`site_stock_points_stock_point\` ADD CONSTRAINT \`FK_e93d8c42c9baf5a0dade42c59ae\` FOREIGN KEY (\`stockPointId\`) REFERENCES \`stock_point\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`site_stock_points_stock_point\` DROP FOREIGN KEY \`FK_e93d8c42c9baf5a0dade42c59ae\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`price\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalVariationId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalProductId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalOrderId\``); + await queryRunner.query(`ALTER TABLE \`template\` DROP COLUMN \`testData\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`price\` decimal(10,2) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalVariationId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalProductId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalOrderId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE \`site_stock_points_stock_point\` ADD CONSTRAINT \`FK_e93d8c42c9baf5a0dade42c59ae\` FOREIGN KEY (\`stockPointId\`) REFERENCES \`stock_point\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + } + +} diff --git a/src/db/migrations/1765330208213-add-site-description.ts b/src/db/migrations/1765330208213-add-site-description.ts new file mode 100644 index 0000000..148ce00 --- /dev/null +++ b/src/db/migrations/1765330208213-add-site-description.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSiteDescription1765330208213 implements MigrationInterface { + name = 'AddSiteDescription1765330208213' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalOrderId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalProductId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`externalVariationId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`subtotal_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`total_tax\` decimal(10,2) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`price\` decimal(10,2) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` CHANGE \`sku\` \`sku\` varchar(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`siteId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`siteId\` int NULL`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`isPackage\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`productId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`price\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`total\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal_tax\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`subtotal\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalVariationId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalProductId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP COLUMN \`externalOrderId\``); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`isPackage\` tinyint NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD \`productId\` int NOT NULL`); + } + +} diff --git a/src/db/seeds/area.seeder.ts b/src/db/seeds/area.seeder.ts new file mode 100644 index 0000000..6adb8ec --- /dev/null +++ b/src/db/seeds/area.seeder.ts @@ -0,0 +1,34 @@ + +import { Seeder, SeederFactoryManager } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { Area } from '../../entity/area.entity'; + +export default class AreaSeeder implements Seeder { + public async run( + dataSource: DataSource, + factoryManager: SeederFactoryManager + ): Promise { + const areaRepository = dataSource.getRepository(Area); + + const areas = [ + { name: 'Australia', code: 'AU' }, + { name: 'Canada', code: 'CA' }, + { name: 'United States', code: 'US' }, + { name: 'Germany', code: 'DE' }, + { name: 'Poland', code: 'PL' }, + ]; + + for (const areaData of areas) { + const existingArea = await areaRepository.findOne({ + where: [ + { name: areaData.name }, + { code: areaData.code } + ] + }); + if (!existingArea) { + const newArea = areaRepository.create(areaData); + await areaRepository.save(newArea); + } + } + } +} diff --git a/src/db/seeds/category.seeder.ts b/src/db/seeds/category.seeder.ts new file mode 100644 index 0000000..4763d60 --- /dev/null +++ b/src/db/seeds/category.seeder.ts @@ -0,0 +1,39 @@ +import { Seeder } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { Category } from '../../entity/category.entity'; + +export default class CategorySeeder implements Seeder { + public async run( + dataSource: DataSource, + ): Promise { + const repository = dataSource.getRepository(Category); + + const categories = [ + { + name: 'nicotine-pouches', + title: 'Nicotine Pouches', + titleCN: '尼古丁袋', + sort: 1 + }, + { + name: 'vape', + title: 'vape', + titleCN: '电子烟', + sort: 2 + }, + { + name: 'pouches-can', + title: 'Pouches Can', + titleCN: '口含烟盒', + sort: 3 + }, + ]; + + for (const cat of categories) { + const existing = await repository.findOne({ where: { name: cat.name } }); + if (!existing) { + await repository.save(cat); + } + } + } +} diff --git a/src/db/seeds/category_attribute.seeder.ts b/src/db/seeds/category_attribute.seeder.ts new file mode 100644 index 0000000..6f11ea5 --- /dev/null +++ b/src/db/seeds/category_attribute.seeder.ts @@ -0,0 +1,62 @@ +import { Seeder } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { Dict } from '../../entity/dict.entity'; +import { Category } from '../../entity/category.entity'; +import { CategoryAttribute } from '../../entity/category_attribute.entity'; + +export default class CategoryAttributeSeeder implements Seeder { + public async run( + dataSource: DataSource, + ): Promise { + const dictRepository = dataSource.getRepository(Dict); + const categoryRepository = dataSource.getRepository(Category); + const categoryAttributeRepository = dataSource.getRepository(CategoryAttribute); + + // 1. 确保属性字典存在 + const attributeNames = ['brand', 'strength', 'flavor', 'size', 'humidity']; + const attributeDicts: Dict[] = []; + + for (const name of attributeNames) { + let dict = await dictRepository.findOne({ where: { name } }); + if (!dict) { + dict = new Dict(); + dict.name = name; + dict.title = name.charAt(0).toUpperCase() + name.slice(1); + dict.deletable = false; + dict = await dictRepository.save(dict); + console.log(`Created Dict: ${name}`); + } + attributeDicts.push(dict); + } + + // 2. 获取 'nicotine-pouches' 分类 (由 CategorySeeder 创建) + const nicotinePouchesCategory = await categoryRepository.findOne({ + where: { + name: 'nicotine-pouches' + } + }); + + if (!nicotinePouchesCategory) { + console.warn('Category "nicotine-pouches" not found. Skipping attribute linking. Please ensure CategorySeeder runs first.'); + return; + } + + // 3. 绑定属性到 'nicotine-pouches' 分类 + for (const attrDict of attributeDicts) { + const existing = await categoryAttributeRepository.findOne({ + where: { + category: { id: nicotinePouchesCategory.id }, + attributeDict: { id: attrDict.id } + } + }); + + if (!existing) { + const link = new CategoryAttribute(); + link.category = nicotinePouchesCategory; + link.attributeDict = attrDict; + await categoryAttributeRepository.save(link); + console.log(`Linked ${attrDict.name} to ${nicotinePouchesCategory.name}`); + } + } + } +} diff --git a/src/db/seeds/dict.seeder.ts b/src/db/seeds/dict.seeder.ts new file mode 100644 index 0000000..0201bd7 --- /dev/null +++ b/src/db/seeds/dict.seeder.ts @@ -0,0 +1,175 @@ +import { Seeder } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { Dict } from '../../entity/dict.entity'; +import { DictItem } from '../../entity/dict_item.entity'; + +export default class DictSeeder implements Seeder { + /** + * 格式化名称为 kebab-case + * @param name 需要格式化的名称 + * @returns 格式化后的名称 + */ + private formatName(name: string): string { + // return String(name).replace(/[\_\s.]+/g, '-').toLowerCase(); + // 只替换空格和下划线 + return String(name).replace(/[\_\s]+/g, '-').toLowerCase(); + } + + public async run( + dataSource: DataSource, + ): Promise { + const dictRepository = dataSource.getRepository(Dict); + const dictItemRepository = dataSource.getRepository(DictItem); + + const flavorsData = [ + { name: 'bellini', title: 'Bellini', titleCn: '贝利尼', shortName: 'BL' }, + { name: 'max-polarmint', title: 'Max Polarmint', titleCn: '马克斯薄荷', shortName: 'MP' }, + { name: 'blueberry', title: 'Blueberry', titleCn: '蓝莓', shortName: 'BB' }, + { name: 'citrus', title: 'Citrus', titleCn: '柑橘', shortName: 'CT' }, + { name: 'wintergreen', title: 'Wintergreen', titleCn: '冬绿薄荷', shortName: 'WG' }, + { name: 'cool-mint', title: 'COOL MINT', titleCn: '清凉薄荷', shortName: 'CM' }, + { name: 'juicy-peach', title: 'JUICY PEACH', titleCn: '多汁蜜桃', shortName: 'JP' }, + { name: 'orange', title: 'ORANGE', titleCn: '橙子', shortName: 'OR' }, + { name: 'peppermint', title: 'PEPPERMINT', titleCn: '胡椒薄荷', shortName: 'PP' }, + { name: 'spearmint', title: 'SPEARMINT', titleCn: '绿薄荷', shortName: 'SM' }, + { name: 'strawberry', title: 'STRAWBERRY', titleCn: '草莓', shortName: 'SB' }, + { name: 'watermelon', title: 'WATERMELON', titleCn: '西瓜', shortName: 'WM' }, + { name: 'coffee', title: 'COFFEE', titleCn: '咖啡', shortName: 'CF' }, + { name: 'lemonade', title: 'LEMONADE', titleCn: '柠檬水', shortName: 'LN' }, + { name: 'apple-mint', title: 'apple mint', titleCn: '苹果薄荷', shortName: 'AM' }, + { name: 'peach', title: 'PEACH', titleCn: '桃子', shortName: 'PC' }, + { name: 'mango', title: 'Mango', titleCn: '芒果', shortName: 'MG' }, + { name: 'ice-wintergreen', title: 'ICE WINTERGREEN', titleCn: '冰冬绿薄荷', shortName: 'IWG' }, + { name: 'pink-lemonade', title: 'Pink Lemonade', titleCn: '粉红柠檬水', shortName: 'PLN' }, + { name: 'blackcherry', title: 'Blackcherry', titleCn: '黑樱桃', shortName: 'BC' }, + { name: 'fresh-mint', title: 'fresh mint', titleCn: '清新薄荷', shortName: 'FM' }, + { name: 'strawberry-lychee', title: 'Strawberry Lychee', titleCn: '草莓荔枝', shortName: 'SBL' }, + { name: 'passion-fruit', title: 'Passion Fruit', titleCn: '百香果', shortName: 'PF' }, + { name: 'banana-lce', title: 'Banana lce', titleCn: '香蕉冰', shortName: 'BI' }, + { name: 'bubblegum', title: 'Bubblegum', titleCn: '泡泡糖', shortName: 'BG' }, + { name: 'mango-lce', title: 'Mango lce', titleCn: '芒果冰', shortName: 'MI' }, + { name: 'grape-lce', title: 'Grape lce', titleCn: '葡萄冰', shortName: 'GI' }, + { name: 'apple', title: 'apple', titleCn: '苹果', shortName: 'AP' }, + { name: 'grape', title: 'grape', titleCn: '葡萄', shortName: 'GR' }, + { name: 'cherry', title: 'cherry', titleCn: '樱桃', shortName: 'CH' }, + { name: 'lemon', title: 'lemon', titleCn: '柠檬', shortName: 'LM' }, + { name: 'razz', title: 'razz', titleCn: '覆盆子', shortName: 'RZ' }, + { name: 'pineapple', title: 'pineapple', titleCn: '菠萝', shortName: 'PA' }, + { name: 'berry', title: 'berry', titleCn: '浆果', shortName: 'BR' }, + { name: 'fruit', title: 'fruit', titleCn: '水果', shortName: 'FR' }, + { name: 'mint', title: 'mint', titleCn: '薄荷', shortName: 'MT' }, + { name: 'menthol', title: 'menthol', titleCn: '薄荷醇', shortName: 'MH' }, + ]; + + const brandsData = [ + { name: 'yoone', title: 'Yoone', titleCn: '', shortName: 'YN' }, + { name: 'white-fox', title: 'White Fox', titleCn: '', shortName: 'WF' }, + { name: 'zyn', title: 'ZYN', titleCn: '', shortName: 'ZN' }, + { name: 'zonnic', title: 'Zonnic', titleCn: '', shortName: 'ZC' }, + { name: 'zolt', title: 'Zolt', titleCn: '', shortName: 'ZT' }, + { name: 'velo', title: 'Velo', titleCn: '', shortName: 'VL' }, + { name: 'lucy', title: 'Lucy', titleCn: '', shortName: 'LC' }, + { name: 'egp', title: 'EGP', titleCn: '', shortName: 'EP' }, + { name: 'bridge', title: 'Bridge', titleCn: '', shortName: 'BR' }, + { name: 'zex', title: 'ZEX', titleCn: '', shortName: 'ZX' }, + { name: 'sesh', title: 'Sesh', titleCn: '', shortName: 'SH' }, + { name: 'pablo', title: 'Pablo', titleCn: '', shortName: 'PB' }, + ]; + + const strengthsData = [ + { name: '2mg', title: '2MG', titleCn: '2毫克', shortName: '2M' }, + { name: '3mg', title: '3MG', titleCn: '3毫克', shortName: '3M' }, + { name: '4mg', title: '4MG', titleCn: '4毫克', shortName: '4M' }, + { name: '6mg', title: '6MG', titleCn: '6毫克', shortName: '6M' }, + { name: '6.5mg', title: '6.5MG', titleCn: '6.5毫克', shortName: '6.5M' }, + { name: '9mg', title: '9MG', titleCn: '9毫克', shortName: '9M' }, + { name: '12mg', title: '12MG', titleCn: '12毫克', shortName: '12M' }, + { name: '16.5mg', title: '16.5MG', titleCn: '16.5毫克', shortName: '16.5M' }, + { name: '18mg', title: '18MG', titleCn: '18毫克', shortName: '18M' }, + { name: '30mg', title: '30MG', titleCn: '30毫克', shortName: '30M' }, + ]; + + // 初始化语言字典 + const locales = [ + { name: 'zh-cn', title: '简体中文', titleCn: '简体中文', shortName: 'CN' }, + { name: 'en-us', title: 'English', titleCn: '英文', shortName: 'EN' }, + ]; + + for (const locale of locales) { + await this.createOrFindDict(dictRepository, locale); + } + + // 添加示例翻译条目 + const zhDict = await dictRepository.findOne({ where: { name: 'zh-cn' } }); + const enDict = await dictRepository.findOne({ where: { name: 'en-us' } }); + + const translations = [ + { name: 'common-save', zh: '保存', en: 'Save' }, + { name: 'common-cancel', zh: '取消', en: 'Cancel' }, + { name: 'common-success', zh: '操作成功', en: 'Success' }, + { name: 'common-failure', zh: '操作失败', en: 'Failure' }, + ]; + + for (const t of translations) { + // 添加中文翻译 + let item = await dictItemRepository.findOne({ where: { name: t.name, dict: { id: zhDict.id } } }); + if (!item) { + await dictItemRepository.save({ name: t.name, title: t.zh, titleCn: t.zh, shortName: t.zh.substring(0, 2).toUpperCase(), dict: zhDict }); + } + + // 添加英文翻译 + item = await dictItemRepository.findOne({ where: { name: t.name, dict: { id: enDict.id } } }); + if (!item) { + await dictItemRepository.save({ name: t.name, title: t.en, titleCn: t.en, shortName: t.en.substring(0, 2).toUpperCase(), dict: enDict }); + } + } + + const brandDict = await this.createOrFindDict(dictRepository, { name: 'brand', title: '品牌', titleCn: '品牌', shortName: 'BR' }); + const flavorDict = await this.createOrFindDict(dictRepository, { name: 'flavor', title: '口味', titleCn: '口味', shortName: 'FL' }); + const strengthDict = await this.createOrFindDict(dictRepository, { name: 'strength', title: '强度', titleCn: '强度', shortName: 'ST' }); + + // 遍历品牌数据 + await this.seedDictItems(dictItemRepository, brandDict, brandsData); + + // 遍历口味数据 + await this.seedDictItems(dictItemRepository, flavorDict, flavorsData); + + // 遍历强度数据 + await this.seedDictItems(dictItemRepository, strengthDict, strengthsData); + } + + /** + * 创建或查找字典 + * @param repo DictRepository + * @param dictInfo 字典信息 + * @returns Dict 实例 + */ + private async createOrFindDict(repo: any, dictInfo: { name: string; title: string; titleCn: string; shortName: string }): Promise { + // 格式化 name + const formattedName = this.formatName(dictInfo.name); + let dict = await repo.findOne({ where: { name: formattedName } }); + if (!dict) { + // 如果字典不存在,则使用格式化后的 name 创建新字典 + dict = await repo.save({ name: formattedName, title: dictInfo.title, titleCn: dictInfo.titleCn, shortName: dictInfo.shortName }); + } + return dict; + } + + /** + * 填充字典项 + * @param repo DictItemRepository + * @param dict 字典实例 + * @param items 字典项数组 + */ + private async seedDictItems(repo: any, dict: Dict, items: { name: string; title: string; titleCn: string; shortName: string }[]): Promise { + for (const item of items) { + // 格式化 name + const formattedName = this.formatName(item.name); + const existingItem = await repo.findOne({ where: { name: formattedName, dict: { id: dict.id } } }); + if (!existingItem) { + // 如果字典项不存在,则使用格式化后的 name 创建新字典项 + await repo.save({ name: formattedName, title: item.title, titleCn: item.titleCn, shortName: item.shortName, dict }); + } + } + } +} diff --git a/src/db/seeds/template.seeder.ts b/src/db/seeds/template.seeder.ts new file mode 100644 index 0000000..be51218 --- /dev/null +++ b/src/db/seeds/template.seeder.ts @@ -0,0 +1,72 @@ +import { Seeder, SeederFactoryManager } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { Template } from '../../entity/template.entity'; + +/** + * @class TemplateSeeder + * @description 模板数据填充器,用于在数据库初始化时插入默认的模板数据. + */ +export default class TemplateSeeder implements Seeder { + /** + * @method run + * @description 执行数据填充操作.如果模板不存在,则创建它;如果存在,则更新它. + * @param {DataSource} dataSource - 数据源实例,用于获取 repository. + * @param {SeederFactoryManager} factoryManager - Seeder 工厂管理器. + */ + public async run( + dataSource: DataSource, + factoryManager: SeederFactoryManager + ): Promise { + // 获取 Template 实体的 repository + const templateRepository = dataSource.getRepository(Template); + + const templates = [ + { + name: 'product.sku', + value: '<%= it.brand %>-<%=it.category%>-<%= it.flavor %>-<%= it.strength %>-<%= it.humidity %>', + description: '产品SKU模板', + testData: JSON.stringify({ + brand: 'Brand', + category: 'Category', + flavor: 'Flavor', + strength: '10mg', + humidity: 'Dry', + }), + }, + { + name: 'product.title', + value: '<%= it.brand %> <%= it.flavor %> <%= it.strength %> <%= it.humidity %>', + description: '产品标题模板', + testData: JSON.stringify({ + brand: 'Brand', + flavor: 'Flavor', + strength: '10mg', + humidity: 'Dry', + }), + }, + ]; + + for (const t of templates) { + // 检查模板是否已存在 + const existingTemplate = await templateRepository.findOne({ + where: { name: t.name }, + }); + + if (existingTemplate) { + // 如果存在,则更新 + existingTemplate.value = t.value; + existingTemplate.description = t.description; + existingTemplate.testData = t.testData; + await templateRepository.save(existingTemplate); + } else { + // 如果不存在,则创建并保存 + const template = new Template(); + template.name = t.name; + template.value = t.value; + template.description = t.description; + template.testData = t.testData; + await templateRepository.save(template); + } + } + } +} diff --git a/src/dto/area.dto.ts b/src/dto/area.dto.ts new file mode 100644 index 0000000..2523286 --- /dev/null +++ b/src/dto/area.dto.ts @@ -0,0 +1,29 @@ + +import { ApiProperty } from '@midwayjs/swagger'; +import { Rule, RuleType } from '@midwayjs/validate'; + +export class CreateAreaDTO { + @ApiProperty({ description: '编码' }) + @Rule(RuleType.string().required()) + code: string; +} + +export class UpdateAreaDTO { + @ApiProperty({ description: '编码', required: false }) + @Rule(RuleType.string()) + code?: string; +} + +export class QueryAreaDTO { + @ApiProperty({ description: '当前页', required: false, default: 1 }) + @Rule(RuleType.number().integer().min(1).default(1)) + currentPage?: number; + + @ApiProperty({ description: '每页数量', required: false, default: 10 }) + @Rule(RuleType.number().integer().min(1).default(10)) + pageSize?: number; + + @ApiProperty({ description: '关键词(名称或编码)', required: false }) + @Rule(RuleType.string()) + keyword?: string; +} diff --git a/src/dto/dict.dto.ts b/src/dto/dict.dto.ts new file mode 100644 index 0000000..6fae6aa --- /dev/null +++ b/src/dto/dict.dto.ts @@ -0,0 +1,62 @@ +import { Rule, RuleType } from '@midwayjs/validate'; + +// 创建字典的数据传输对象 +export class CreateDictDTO { + @Rule(RuleType.string().required()) + name: string; // 字典名称 + + @Rule(RuleType.string().required()) + title: string; // 字典标题 +} + +// 更新字典的数据传输对象 +export class UpdateDictDTO { + @Rule(RuleType.string()) + name?: string; // 字典名称 (可选) + + @Rule(RuleType.string()) + title?: string; // 字典标题 (可选) +} + +// 创建字典项的数据传输对象 +export class CreateDictItemDTO { + @Rule(RuleType.string().required()) + name: string; // 字典项名称 + + @Rule(RuleType.string().required()) + title: string; // 字典项标题 + + @Rule(RuleType.string().allow('').allow(null)) + titleCN?: string; // 字典项中文标题 (可选) + + @Rule(RuleType.string().allow('').allow(null)) + image?: string; // 图片 (可选) + + @Rule(RuleType.string().allow('').allow(null)) + shortName?: string; // 简称 (可选) + + @Rule(RuleType.number().required()) + dictId: number; // 所属字典的ID +} + +// 更新字典项的数据传输对象 +export class UpdateDictItemDTO { + @Rule(RuleType.string()) + name?: string; // 字典项名称 (可选) + + @Rule(RuleType.string()) + title?: string; // 字典项标题 (可选) + + @Rule(RuleType.string().allow('').allow(null)) + titleCN?: string; // 字典项中文标题 (可选) + + @Rule(RuleType.string().allow(null)) + value?: string; // 字典项值 (可选) + + @Rule(RuleType.string().allow('').allow(null)) + image?: string; // 图片 (可选) + + @Rule(RuleType.string().allow('').allow(null)) + shortName?: string; // 简称 (可选) + +} diff --git a/src/dto/freightcom.dto.ts b/src/dto/freightcom.dto.ts index 2bba475..32b998d 100644 --- a/src/dto/freightcom.dto.ts +++ b/src/dto/freightcom.dto.ts @@ -8,7 +8,7 @@ export type PackagingType = // | PackagingCourierPak // | PackagingEnvelope; -// 定义包装类型的枚举,用于 API 文档描述 +// 定义包装类型的枚举,用于 API 文档描述 export enum PackagingTypeEnum { Pallet = 'pallet', Package = 'package', diff --git a/src/dto/order.dto.ts b/src/dto/order.dto.ts index 0f0b133..34e35db 100644 --- a/src/dto/order.dto.ts +++ b/src/dto/order.dto.ts @@ -61,8 +61,8 @@ export class QueryOrderDTO { externalOrderId: string; @ApiProperty() - @Rule(RuleType.string()) - siteId: string; + @Rule(RuleType.number()) + siteId: number; @ApiProperty() @Rule(RuleType.string().allow('')) @@ -92,7 +92,7 @@ export class QueryOrderDTO { @Rule(RuleType.string()) payment_method: string; - @ApiProperty({ description: '仅订阅订单(父订阅订单或包含订阅商品)' }) + @ApiProperty({ description: '仅订阅订单(父订阅订单或包含订阅商品)' }) @Rule(RuleType.bool().default(false)) isSubscriptionOnly?: boolean; } @@ -115,8 +115,8 @@ export class QueryOrderSalesDTO { pageSize: number; @ApiProperty() - @Rule(RuleType.string()) - siteId: string; + @Rule(RuleType.number()) + siteId: number; @ApiProperty() @Rule(RuleType.string()) @@ -156,8 +156,8 @@ export class QueryOrderItemDTO { pageSize: number; @ApiProperty() - @Rule(RuleType.string().allow('')) - siteId: string; + @Rule(RuleType.number().allow('')) + siteId: number; @ApiProperty() @Rule(RuleType.string().allow('')) diff --git a/src/dto/product.dto.ts b/src/dto/product.dto.ts index d952151..07a8df2 100644 --- a/src/dto/product.dto.ts +++ b/src/dto/product.dto.ts @@ -1,6 +1,31 @@ import { ApiProperty } from '@midwayjs/swagger'; import { Rule, RuleType } from '@midwayjs/validate'; +/** + * 属性输入DTO + */ +export class AttributeInputDTO { + @ApiProperty({ description: '属性字典标识', example: 'brand' }) + @Rule(RuleType.string()) + dictName?: string; + + @ApiProperty({ description: '属性值', example: 'ZYN' }) + @Rule(RuleType.string()) + value?: string; + + @ApiProperty({ description: '属性ID', example: 1 }) + @Rule(RuleType.number()) + id?: number; + + @ApiProperty({ description: '属性名称', example: 'ZYN' }) + @Rule(RuleType.string()) + name?: string; + + @ApiProperty({ description: '属性显示名称', example: 'ZYN' }) + @Rule(RuleType.string()) + title?: string; +} + /** * DTO 用于创建产品 */ @@ -13,162 +38,248 @@ export class CreateProductDTO { @Rule(RuleType.string().required().empty({ message: '产品名称不能为空' })) name: string; + @ApiProperty({ description: '产品中文名称', required: false }) + @Rule(RuleType.string().allow('').optional()) + nameCn?: string; + @ApiProperty({ example: '产品描述', description: '产品描述' }) @Rule(RuleType.string()) description: string; - @ApiProperty({ example: '1', description: '分类 ID' }) - @Rule(RuleType.number()) - categoryId: number; + @ApiProperty({ example: '产品简短描述', description: '产品简短描述' }) + @Rule(RuleType.string().optional()) + shortDescription?: string; - @ApiProperty() - @Rule(RuleType.number()) - strengthId: number; - - @ApiProperty() - @Rule(RuleType.number()) - flavorsId: number; - - @ApiProperty() + @ApiProperty({ description: '产品 SKU', required: false }) @Rule(RuleType.string()) - humidity: string; + sku?: string; + + @ApiProperty({ description: '分类ID (DictItem ID)', required: false }) + @Rule(RuleType.number()) + categoryId?: number; + + @ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + siteSkus?: string[]; + + // 通用属性输入(通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等) + @ApiProperty({ description: '属性列表', type: 'array' }) + @Rule(RuleType.array().required()) + attributes: AttributeInputDTO[]; + + // 商品价格 + @ApiProperty({ description: '价格', example: 99.99, required: false }) + @Rule(RuleType.number()) + price?: number; + + // 促销价格 + @ApiProperty({ description: '促销价格', example: 99.99, required: false }) + @Rule(RuleType.number()) + promotionPrice?: number; + + + + // 商品类型(默认 single; bundle 需手动设置组成) + @ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], default: 'single', required: false }) + @Rule(RuleType.string().valid('single', 'bundle').default('single')) + type?: string; + + // 仅当 type 为 'bundle' 时,才需要提供 components + @ApiProperty({ description: '产品组成', type: 'array', required: false }) + @Rule( + RuleType.array() + .items( + RuleType.object({ + sku: RuleType.string().required(), + quantity: RuleType.number().required(), + }) + ) + .when('type', { + is: 'bundle', + then: RuleType.array().required(), + }) + ) + components?: { sku: string; quantity: number }[]; } /** * DTO 用于更新产品 */ -export class UpdateProductDTO extends CreateProductDTO { +export class UpdateProductDTO { @ApiProperty({ example: 'ZYN 6MG WINTERGREEN', description: '产品名称' }) @Rule(RuleType.string()) - name: string; + name?: string; + + @ApiProperty({ description: '产品中文名称', required: false }) + @Rule(RuleType.string().allow('').optional()) + nameCn?: string; + + @ApiProperty({ example: '产品描述', description: '产品描述' }) + @Rule(RuleType.string()) + description?: string; + + @ApiProperty({ example: '产品简短描述', description: '产品简短描述' }) + @Rule(RuleType.string().optional()) + shortDescription?: string; + + @ApiProperty({ description: '产品 SKU', required: false }) + @Rule(RuleType.string()) + sku?: string; + + @ApiProperty({ description: '分类ID (DictItem ID)', required: false }) + @Rule(RuleType.number()) + categoryId?: number; + + @ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + siteSkus?: string[]; + + // 商品价格 + @ApiProperty({ description: '价格', example: 99.99, required: false }) + @Rule(RuleType.number()) + price?: number; + + // 促销价格 + @ApiProperty({ description: '促销价格', example: 99.99, required: false }) + @Rule(RuleType.number()) + promotionPrice?: number; + + + + // 属性更新(可选, 支持增量替换指定字典的属性项) + @ApiProperty({ description: '属性列表', type: 'array', required: false }) + @Rule(RuleType.array()) + attributes?: AttributeInputDTO[]; + + // 商品类型(single 或 bundle) + @ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], required: false }) + @Rule(RuleType.string().valid('single', 'bundle')) + type?: string; + + // 仅当 type 为 'bundle' 时,才需要提供 components + @ApiProperty({ description: '产品组成', type: 'array', required: false }) + @Rule( + RuleType.array() + .items( + RuleType.object({ + sku: RuleType.string().required(), + quantity: RuleType.number().required(), + }) + ) + .when('type', { + is: 'bundle', + then: RuleType.array().optional(), + }) + ) + components?: { sku: string; quantity: number }[]; +} + + +/** + * DTO 用于批量更新产品属性 + */ +export class BatchUpdateProductDTO { + @ApiProperty({ description: '产品ID列表', type: 'array', required: true }) + @Rule(RuleType.array().items(RuleType.number()).required().min(1)) + ids: number[]; + + @ApiProperty({ example: 'ZYN 6MG WINTERGREEN', description: '产品名称', required: false }) + @Rule(RuleType.string().optional()) + name?: string; + + @ApiProperty({ description: '产品中文名称', required: false }) + @Rule(RuleType.string().allow('').optional()) + nameCn?: string; + + @ApiProperty({ example: '产品描述', description: '产品描述', required: false }) + @Rule(RuleType.string().optional()) + description?: string; + + @ApiProperty({ example: '产品简短描述', description: '产品简短描述', required: false }) + @Rule(RuleType.string().optional()) + shortDescription?: string; + + @ApiProperty({ description: '产品 SKU', required: false }) + @Rule(RuleType.string().optional()) + sku?: string; + + @ApiProperty({ description: '分类ID (DictItem ID)', required: false }) + @Rule(RuleType.number().optional()) + categoryId?: number; + + @ApiProperty({ description: '站点 SKU 列表', type: 'array', required: false }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + siteSkus?: string[]; + + @ApiProperty({ description: '价格', example: 99.99, required: false }) + @Rule(RuleType.number().optional()) + price?: number; + + @ApiProperty({ description: '促销价格', example: 99.99, required: false }) + @Rule(RuleType.number().optional()) + promotionPrice?: number; + + @ApiProperty({ description: '属性列表', type: 'array', required: false }) + @Rule(RuleType.array().optional()) + attributes?: AttributeInputDTO[]; + + @ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], required: false }) + @Rule(RuleType.string().valid('single', 'bundle').optional()) + type?: string; +} + +/** + * DTO 用于批量删除产品 + */ +export class BatchDeleteProductDTO { + @ApiProperty({ description: '产品ID列表', type: 'array', required: true }) + @Rule(RuleType.array().items(RuleType.number()).required().min(1)) + ids: number[]; +} + +/** + * DTO 用于创建分类属性绑定 + */ +export class CreateCategoryAttributeDTO { + @ApiProperty({ description: '分类字典项ID', example: 1 }) + @Rule(RuleType.number().required()) + categoryItemId: number; + + @ApiProperty({ description: '属性字典ID列表', example: [2, 3] }) + @Rule(RuleType.array().items(RuleType.number()).required()) + attributeDictIds: number[]; } /** * DTO 用于分页查询产品 */ export class QueryProductDTO { - @ApiProperty({ example: '1', description: '页码' }) - @Rule(RuleType.number()) + @ApiProperty({ description: '当前页', example: 1 }) + @Rule(RuleType.number().default(1)) current: number; - @ApiProperty({ example: '10', description: '每页大小' }) - @Rule(RuleType.number()) + @ApiProperty({ description: '每页数量', example: 10 }) + @Rule(RuleType.number().default(10)) pageSize: number; - @ApiProperty({ example: 'ZYN', description: '关键字' }) + @ApiProperty({ description: '搜索关键字', required: false }) @Rule(RuleType.string()) - name: string; + name?: string; - @ApiProperty({ example: '1', description: '分类 ID' }) - @Rule(RuleType.string()) - categoryId: number; -} - -/** - * DTO 用于创建分类 - */ -export class CreateCategoryDTO { - @ApiProperty({ example: 'ZYN', description: '分类名称', required: true }) - @Rule(RuleType.string().required().empty({ message: '分类名称不能为空' })) - name: string; - - @Rule(RuleType.string().required().empty({ message: 'key不能为空' })) - unique_key: string; -} - -/** - * DTO 用于更新分类 - */ -export class UpdateCategoryDTO { - @ApiProperty({ example: 'ZYN', description: '分类名称' }) - @Rule(RuleType.string()) - name: string; -} - -/** - * DTO 用于查询分类(支持分页) - */ -export class QueryCategoryDTO { - @ApiProperty({ example: '1', description: '页码' }) + @ApiProperty({ description: '分类ID', required: false }) @Rule(RuleType.number()) - current: number; // 页码 + categoryId?: number; - @ApiProperty({ example: '10', description: '每页大小' }) + @ApiProperty({ description: '品牌ID', required: false }) @Rule(RuleType.number()) - pageSize: number; // 每页大小 + brandId?: number; - @ApiProperty({ example: 'ZYN', description: '关键字' }) + @ApiProperty({ description: '排序字段', required: false }) @Rule(RuleType.string()) - name: string; // 搜索关键字(支持模糊查询) + sortField?: string; + + @ApiProperty({ description: '排序方式', required: false }) + @Rule(RuleType.string().valid('ascend', 'descend')) + sortOrder?: string; } -export class CreateFlavorsDTO { - @ApiProperty({ example: 'ZYN', description: '分类名称', required: true }) - @Rule(RuleType.string().required().empty({ message: '分类名称不能为空' })) - name: string; - - @Rule(RuleType.string().required().empty({ message: 'key不能为空' })) - unique_key: string; -} - -export class UpdateFlavorsDTO { - @ApiProperty({ example: 'ZYN', description: '分类名称' }) - @Rule(RuleType.string()) - name: string; -} - -export class QueryFlavorsDTO { - @ApiProperty({ example: '1', description: '页码' }) - @Rule(RuleType.number()) - current: number; // 页码 - - @ApiProperty({ example: '10', description: '每页大小' }) - @Rule(RuleType.number()) - pageSize: number; // 每页大小 - - @ApiProperty({ example: 'ZYN', description: '关键字' }) - @Rule(RuleType.string()) - name: string; // 搜索关键字(支持模糊查询) -} - -export class CreateStrengthDTO { - @ApiProperty({ example: 'ZYN', description: '分类名称', required: true }) - @Rule(RuleType.string().required().empty({ message: '分类名称不能为空' })) - name: string; - - @Rule(RuleType.string().required().empty({ message: 'key不能为空' })) - unique_key: string; -} - -export class UpdateStrengthDTO { - @ApiProperty({ example: 'ZYN', description: '分类名称' }) - @Rule(RuleType.string()) - name: string; -} - -export class QueryStrengthDTO { - @ApiProperty({ example: '1', description: '页码' }) - @Rule(RuleType.number()) - current: number; // 页码 - - @ApiProperty({ example: '10', description: '每页大小' }) - @Rule(RuleType.number()) - pageSize: number; // 每页大小 - - @ApiProperty({ example: 'ZYN', description: '关键字' }) - @Rule(RuleType.string()) - name: string; // 搜索关键字(支持模糊查询) -} - -export class SkuItemDTO { - @ApiProperty({ description: '产品 ID' }) - productId: number; - - @ApiProperty({ description: 'sku 编码' }) - sku: string; -} - -export class BatchSetSkuDTO { - @ApiProperty({ description: 'sku 数据列表', type: [SkuItemDTO] }) - skus: SkuItemDTO[]; -} diff --git a/src/dto/reponse.dto.ts b/src/dto/reponse.dto.ts index f4aae3c..6180703 100644 --- a/src/dto/reponse.dto.ts +++ b/src/dto/reponse.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@midwayjs/swagger'; -import { Category } from '../entity/category.entity'; import { Order } from '../entity/order.entity'; import { Product } from '../entity/product.entity'; import { StockPoint } from '../entity/stock_point.entity'; @@ -18,12 +17,11 @@ import { Service } from '../entity/service.entity'; import { RateDTO } from './freightcom.dto'; import { ShippingAddress } from '../entity/shipping_address.entity'; import { OrderItem } from '../entity/order_item.entity'; -import { OrderRefundItem } from '../entity/order_retund_item.entity'; +import { OrderRefundItem } from '../entity/order_refund_item.entity'; import { OrderNote } from '../entity/order_note.entity'; import { PaymentMethodDTO } from './logistics.dto'; -import { Flavors } from '../entity/flavors.entity'; -import { Strength } from '../entity/strength.entity'; import { Subscription } from '../entity/subscription.entity'; +import { Dict } from '../entity/dict.entity'; export class BooleanRes extends SuccessWrapper(Boolean) {} //网站配置返回数据 @@ -35,18 +33,49 @@ export class ProductListRes extends SuccessWrapper(ProductPaginatedResponse) {} //产品返回数据 export class ProductRes extends SuccessWrapper(Product) {} export class ProductsRes extends SuccessArrayWrapper(Product) {} -//产品分类返分页数据 -export class CategoryPaginatedResponse extends PaginatedWrapper(Category) {} -export class FlavorsPaginatedResponse extends PaginatedWrapper(Flavors) {} -export class StrengthPaginatedResponse extends PaginatedWrapper(Strength) {} -//产品分类返分页返回数据 -export class ProductCatListRes extends SuccessWrapper( - CategoryPaginatedResponse +//产品品牌返分页数据 +export class BrandPaginatedResponse extends PaginatedWrapper(Dict) {} +//产品品牌返分页返回数据 +export class ProductBrandListRes extends SuccessWrapper( + BrandPaginatedResponse ) {} -//产品分类返所有数据 -export class ProductCatAllRes extends SuccessArrayWrapper(Category) {} -//产品分类返回数据 -export class ProductCatRes extends SuccessWrapper(Category) {} +//产品品牌返所有数据 +export class ProductBrandAllRes extends SuccessArrayWrapper(Dict) {} +//产品品牌返回数据 +export class ProductBrandRes extends SuccessWrapper(Dict) {} + +//产品口味返分页数据 +export class FlavorsPaginatedResponse extends PaginatedWrapper(Dict) {} +//产品口味返分页返回数据 +export class ProductFlavorsListRes extends SuccessWrapper( + FlavorsPaginatedResponse +) {} +//产品口味返所有数据 +export class ProductFlavorsAllRes extends SuccessArrayWrapper(Dict) {} +//产品口味返回数据 +export class ProductFlavorsRes extends SuccessWrapper(Dict) {} + +//产品规格返分页数据 +export class StrengthPaginatedResponse extends PaginatedWrapper(Dict) {} +//产品规格返分页返回数据 +export class ProductStrengthListRes extends SuccessWrapper( + StrengthPaginatedResponse +) {} +//产品规格返所有数据 +export class ProductStrengthAllRes extends SuccessArrayWrapper(Dict) {} +//产品规格返返回数据 +export class ProductStrengthRes extends SuccessWrapper(Dict) {} + +// 产品尺寸返分页数据 +export class SizePaginatedResponse extends PaginatedWrapper(Dict) {} +// 产品尺寸返分页返回数据 +export class ProductSizeListRes extends SuccessWrapper( + SizePaginatedResponse +) {} +// 产品尺寸返所有数据 +export class ProductSizeAllRes extends SuccessArrayWrapper(Dict) {} +// 产品尺寸返回数据 +export class ProductSizeRes extends SuccessWrapper(Dict) {} //产品分页数据 export class WpProductPaginatedResponse extends PaginatedWrapper( @@ -119,7 +148,7 @@ export class PaymentMethodListRes extends SuccessArrayWrapper( PaymentMethodDTO ) {} -// 订阅分页数据(列表 + 总数等分页信息) +// 订阅分页数据(列表 + 总数等分页信息) export class SubscriptionPaginatedResponse extends PaginatedWrapper(Subscription) {} -// 订阅分页返回数据(统一成功包装) +// 订阅分页返回数据(统一成功包装) export class SubscriptionListRes extends SuccessWrapper(SubscriptionPaginatedResponse) {} diff --git a/src/dto/shopyy.dto.ts b/src/dto/shopyy.dto.ts new file mode 100644 index 0000000..fe63d59 --- /dev/null +++ b/src/dto/shopyy.dto.ts @@ -0,0 +1,373 @@ +// Shopyy 平台原始数据类型定义 +// 仅包含当前映射逻辑所需字段以保持简洁与类型安全 +export interface ShopyyTag { + id?: number; + name?: string; +} +// 产品类型 +export interface ShopyyProduct { + // 产品主键 + id: number; + // 产品名称或标题 + name?: string; + title?: string; + // 产品类型 + product_type?: string | number; + // 产品状态数值 1为发布 其他为草稿 + status: number; + // 变体信息 + variant?: { + sku?: string; + price?: string; + }; + // 价格 + special_price?: string; + price?: string; + // 库存追踪标识 1表示跟踪 + inventory_tracking?: number; + // 库存数量 + inventory_quantity?: number; + // 图片列表 + images?: Array<{ + id?: number; + src: string; + alt?: string; + position?: string | number; + }>; + // 主图 + image?: { + src: string; + file_name?: string; + alt?: string; + file_size?: number; + width?: number; + height?: number; + id?: number; + position?: number | string; + file_type?: string; + }; + // 标签 + tags?: ShopyyTag[]; + // 变体列表 + variants?: ShopyyVariant[]; + // 分类集合 + collections?: Array<{ id?: number; title?: string }>; + // 规格选项列表 + options?: Array<{ + id?: number; + position?: number | string; + option_name?: string; + values?: Array<{ option_value?: string; id?: number; position?: number }>; + }>; + // 发布与标识 + published_at?: string; + handle?: string; + spu?: string; + // 创建与更新时间 + created_at?: string | number; + updated_at?: string | number; +} + +// 变体类型 +export interface ShopyyVariant { + id: number; + sku?: string; + price?: string; + special_price?: string; + inventory_tracking?: number; + inventory_quantity?: number; + available?: number; + barcode?: string; + weight?: number; + image?: { src: string; id?: number; file_name?: string; alt?: string; position?: number | string }; + position?: number | string; + sku_code?: string; +} + +// 订单类型 +export interface ShopyyOrder { + // 主键与外部ID + id?: number; + order_id?: number; + // 订单号 + order_number?: string; + order_sn?: string; + // 状态 + status?: number | string; + order_status?: number | string; + // 币种 + currency_code?: string; + currency?: string; + // 金额 + total_price?: string | number; + total_amount?: string | number; + current_total_price?: string | number; + current_subtotal_price?: string | number; + current_shipping_price?: string | number; + current_tax_price?: string | number; + current_coupon_price?: string | number; + current_payment_price?: string | number; + // 客户ID + customer_id?: number; + user_id?: number; + // 客户信息 + customer_name?: string; + firstname?: string; + lastname?: string; + customer_email?: string; + email?: string; + // 地址字段 + billing_address?: { + first_name?: string; + last_name?: string; + name?: string; + company?: string; + phone?: string; + address1?: string; + address2?: string; + city?: string; + province?: string; + zip?: string; + country_name?: string; + country_code?: string; + }; + shipping_address?: { + first_name?: string; + last_name?: string; + name?: string; + company?: string; + phone?: string; + address1?: string; + address2?: string; + city?: string; + province?: string; + zip?: string; + country_name?: string; + country_code?: string; + } | string; + telephone?: string; + payment_address?: string; + payment_city?: string; + payment_zone?: string; + payment_postcode?: string; + payment_country?: string; + shipping_city?: string; + shipping_zone?: string; + shipping_postcode?: string; + shipping_country?: string; + // 订单项集合 + products?: Array<{ + id?: number; + name?: string; + product_title?: string; + product_id?: number; + quantity?: number; + price?: string | number; + sku?: string; + sku_code?: string; + }>; + // 支付方式 + payment_method?: string; + payment_id?: number; + payment_cards?: Array<{ + store_id?: number; + card_len?: number; + card_suffix?: number; + year?: number; + payment_status?: number; + created_at?: number; + month?: number; + updated_at?: number; + payment_id?: number; + payment_interface?: string; + card_prefix?: number; + id?: number; + order_id?: number; + card?: string; + transaction_no?: string; + }>; + fulfillments?: Array<{ + payment_tracking_status?: number; + note?: string; + updated_at?: number; + courier_code?: string; + courier_id?: number; + created_at?: number; + tracking_number?: string; + id?: number; + tracking_company?: string; + payment_tracking_result?: string; + payment_tracking_at?: number; + products?: Array<{ order_product_id?: number; quantity?: number; updated_at?: number; created_at?: number; id?: number }>; + }>; + shipping_zone_plans?: Array<{ + shipping_price?: number | string; + updated_at?: number; + created_at?: number; + id?: number; + shipping_zone_name?: string; + shipping_zone_id?: number; + shipping_zone_plan_id?: number; + shipping_zone_plan_name?: string; + }>; + transaction?: { + note?: string; + amount?: number | string; + created_at?: number; + merchant_id?: string; + payment_type?: string; + merchant_account?: string; + updated_at?: number; + payment_id?: number; + admin_id?: number; + admin_name?: string; + id?: number; + payment_method?: string; + transaction_no?: string; + }; + coupon_code?: string; + coupon_name?: string; + store_id?: number; + visitor_id?: string; + currency_rate?: string | number; + landing_page?: string; + note?: string; + admin_note?: string; + source_device?: string; + checkout_type?: string; + version?: string; + brand_id?: number; + tags?: string[]; + financial_status?: number; + fulfillment_status?: number; + // 创建与更新时间可能为时间戳 + created_at?: number | string; + date_added?: string; + updated_at?: number | string; + date_updated?: string; + last_modified?: string; +} + +// 客户类型 +export interface ShopyyCustomer { + // 主键与兼容ID + id?: number; + customer_id?: number; + // 姓名 + first_name?: string; + firstname?: string; + last_name?: string; + lastname?: string; + fullname?: string; + customer_name?: string; + // 联系信息 + email?: string; + customer_email?: string; + contact?: string; + phone?: string; + // 地址集合 + addresses?: any[]; + default_address?: any; + // 国家 + country?: { country_name?: string }; + // 统计字段 + orders_count?: number; + order_count?: number; + orders?: number; + total_spent?: number | string; + total_spend_amount?: number | string; + total_spend_money?: number | string; + // 创建与更新时间可能为时间戳 + created_at?: number | string; + date_added?: string; + updated_at?: number | string; + date_updated?: string; +} + +// 评论类型 +export interface ShopyyReview { + // 主键ID + id: number; + // 产品ID + product_id: number; + // 客户ID + customer_id: number; + // 国家ID + country_id: number; + // IP地址 + ip: string; + // 评分星级 + star: number; + // 客户名称 + customer_name: string; + // 客户邮箱 + customer_email: string; + // 回复内容 + reply_content: string; + // 评论内容 + content: string; + // 状态 1表示正常 + status: number; + // 是否包含图片 0表示不包含 + is_image: number; + // 图片列表 + images: any[]; + // 更新时间戳 + updated_at: number; + // 创建时间戳 + created_at: number; +} + + +export interface ShopyyWebhookEvent { + id: number; + 'event_name': string; + 'event_code': string; + "event_decript": string; + isemail_event: number; + email_event_file: string; + email_event_status: number; + is_webhook: number; + is_script_event: number; + created_at: number; + updated_at: number; +} +export interface ShopyyWebhook { + id: number; + "webhook_name": string; + "url": string; + event_id: number; + event_name: string; + event_code: string; +} + +// 发货相关DTO +export class ShopyyShipOrderItemDTO { + order_item_id: number; + quantity: number; +} + +export class ShopyyShipOrderDTO { + tracking_number?: string; + shipping_provider?: string; + shipping_method?: string; + items?: ShopyyShipOrderItemDTO[]; +} + +export class ShopyyCancelShipOrderDTO { + reason?: string; + shipment_id?: string; +} + +export class ShopyyBatchShipOrderItemDTO { + order_id: string; + tracking_number?: string; + shipping_provider?: string; + shipping_method?: string; + items?: ShopyyShipOrderItemDTO[]; +} + +export class ShopyyBatchShipOrdersDTO { + orders: ShopyyBatchShipOrderItemDTO[]; +} + diff --git a/src/dto/site-api.dto.ts b/src/dto/site-api.dto.ts new file mode 100644 index 0000000..fc9b52a --- /dev/null +++ b/src/dto/site-api.dto.ts @@ -0,0 +1,717 @@ +import { ApiProperty } from '@midwayjs/swagger'; + +export class UnifiedPaginationDTO { + // 分页DTO用于承载统一分页信息与列表数据 + @ApiProperty({ description: '列表数据' }) + items: T[]; + + @ApiProperty({ description: '总数', example: 100 }) + total: number; + + @ApiProperty({ description: '当前页', example: 1 }) + page: number; + + @ApiProperty({ description: '每页数量', example: 20 }) + per_page: number; + + @ApiProperty({ description: '每页数量别名', example: 20 }) + page_size?: number; + + @ApiProperty({ description: '总页数', example: 5 }) + totalPages: number; +} +export class UnifiedTagDTO { + // 标签DTO用于承载统一标签数据 + @ApiProperty({ description: '标签ID' }) + id: number | string; + + @ApiProperty({ description: '标签名称' }) + name: string; +} +export class UnifiedCategoryDTO { + // 分类DTO用于承载统一分类数据 + @ApiProperty({ description: '分类ID' }) + id: number | string; + + @ApiProperty({ description: '分类名称' }) + name: string; +} +export class UnifiedImageDTO { + // 图片DTO用于承载统一图片数据 + @ApiProperty({ description: '图片ID' }) + id: number | string; + + @ApiProperty({ description: '图片URL' }) + src: string; + + @ApiProperty({ description: '图片名称', required: false }) + name?: string; + + @ApiProperty({ description: '替代文本', required: false }) + alt?: string; +} + +export class UnifiedAddressDTO { + // 地址DTO用于承载统一地址数据 + @ApiProperty({ description: '名', required: false }) + first_name?: string; + + @ApiProperty({ description: '姓', required: false }) + last_name?: string; + + @ApiProperty({ description: '全名', required: false }) + fullname?: string; + + @ApiProperty({ description: '公司', required: false }) + company?: string; + + @ApiProperty({ description: '地址1', required: false }) + address_1?: string; + + @ApiProperty({ description: '地址2', required: false }) + address_2?: string; + + @ApiProperty({ description: '城市', required: false }) + city?: string; + + @ApiProperty({ description: '省/州', required: false }) + state?: string; + + @ApiProperty({ description: '邮政编码', required: false }) + postcode?: string; + + @ApiProperty({ description: '国家', required: false }) + country?: string; + + @ApiProperty({ description: '邮箱', required: false }) + email?: string; + + @ApiProperty({ description: '电话', required: false }) + phone?: string; +} + +export class UnifiedOrderLineItemDTO { + // 订单项DTO用于承载统一订单项数据 + @ApiProperty({ description: '订单项ID' }) + id: number | string; + + @ApiProperty({ description: '产品名称' }) + name: string; + + @ApiProperty({ description: '产品ID' }) + product_id: number | string; + + @ApiProperty({ description: '变体ID', required: false }) + variation_id?: number | string; + + @ApiProperty({ description: '数量' }) + quantity: number; + + @ApiProperty({ description: '总计' }) + total: string; + + @ApiProperty({ description: 'SKU' }) + sku: string; +} + +export class UnifiedProductAttributeDTO { + // 产品属性DTO用于承载统一产品属性数据 + @ApiProperty({ description: '属性ID', required: false }) + id?: number | string; + + @ApiProperty({ description: '属性名称' }) + name: string; + + @ApiProperty({ description: '属性位置', example: 0, required: false }) + position?: number; + + @ApiProperty({ description: '对变体是否可见', example: true, required: false }) + visible?: boolean; + + @ApiProperty({ description: '是否为变体属性', example: true, required: false }) + variation?: boolean; + + @ApiProperty({ description: '属性选项', type: [String] }) + options: string[]; +} + +export class UnifiedProductVariationDTO { + // 产品变体DTO用于承载统一产品变体数据 + @ApiProperty({ description: '变体ID' }) + id: number | string; + + @ApiProperty({ description: '变体SKU' }) + sku: string; + + @ApiProperty({ description: '常规价格' }) + regular_price: string; + + @ApiProperty({ description: '销售价格' }) + sale_price: string; + + @ApiProperty({ description: '当前价格' }) + price: string; + + @ApiProperty({ description: '库存状态' }) + stock_status: string; + + @ApiProperty({ description: '库存数量' }) + stock_quantity: number; + + @ApiProperty({ description: '变体图片', type: () => UnifiedImageDTO, required: false }) + image?: UnifiedImageDTO; +} + +export class UnifiedProductDTO { + // 产品DTO用于承载统一产品数据 + @ApiProperty({ description: '产品ID' }) + id: string | number; + + @ApiProperty({ description: '产品名称' }) + name: string; + + @ApiProperty({ description: '产品类型' }) + type: string; + + @ApiProperty({ description: '产品状态' }) + status: string; + + @ApiProperty({ description: '产品SKU' }) + sku: string; + + @ApiProperty({ description: '常规价格' }) + regular_price: string; + + @ApiProperty({ description: '销售价格' }) + sale_price: string; + + @ApiProperty({ description: '当前价格' }) + price: string; + + @ApiProperty({ description: '库存状态' }) + stock_status: string; + + @ApiProperty({ description: '库存数量' }) + stock_quantity: number; + + @ApiProperty({ description: '产品图片', type: () => [UnifiedImageDTO] }) + images: UnifiedImageDTO[]; + + @ApiProperty({ description: '产品标签', type: () => [UnifiedTagDTO], required: false }) + tags?: UnifiedTagDTO[]; + + @ApiProperty({ description: '产品分类', type: () => [UnifiedCategoryDTO], required: false }) + categories?: UnifiedCategoryDTO[]; + + @ApiProperty({ description: '产品属性', type: () => [UnifiedProductAttributeDTO] }) + attributes: UnifiedProductAttributeDTO[]; + + @ApiProperty({ + description: '产品变体', + type: () => [UnifiedProductVariationDTO], + required: false, + }) + variations?: UnifiedProductVariationDTO[]; + + @ApiProperty({ description: '创建时间' }) + date_created: string; + + @ApiProperty({ description: '更新时间' }) + date_modified: string; + + @ApiProperty({ description: '产品链接', required: false }) + permalink?: string; + + @ApiProperty({ + description: '原始数据(保留备用)', + type: 'object', + required: false, + }) + raw?: Record; + + @ApiProperty({ + description: 'ERP产品信息', + type: 'object', + required: false, + }) + erpProduct?: { + id: number; + sku: string; + name: string; + nameCn?: string; + category?: any; + attributes?: any[]; + components?: any[]; + price: number; + promotionPrice: number; + }; +} + +export class UnifiedOrderDTO { + // 订单DTO用于承载统一订单数据 + @ApiProperty({ description: '订单ID' }) + id: string | number; + + @ApiProperty({ description: '订单号' }) + number: string; + + @ApiProperty({ description: '订单状态' }) + status: string; + + @ApiProperty({ description: '货币' }) + currency: string; + + @ApiProperty({ description: '总金额' }) + total: string; + + @ApiProperty({ description: '客户ID' }) + customer_id: number; + + @ApiProperty({ description: '客户姓名' }) + customer_name: string; + + @ApiProperty({ description: '客户邮箱' }) + email: string; + + @ApiProperty({ description: '订单项(具体的商品)', type: () => [UnifiedOrderLineItemDTO] }) + line_items: UnifiedOrderLineItemDTO[]; + + @ApiProperty({ + description: '销售项(兼容前端)', + type: () => [UnifiedOrderLineItemDTO], + required: false, + }) + sales?: UnifiedOrderLineItemDTO[]; + + @ApiProperty({ description: '账单地址', type: () => UnifiedAddressDTO }) + billing: UnifiedAddressDTO; + + @ApiProperty({ description: '收货地址', type: () => UnifiedAddressDTO }) + shipping: UnifiedAddressDTO; + + @ApiProperty({ description: '账单地址全称', required: false }) + billing_full_address?: string; + + @ApiProperty({ description: '收货地址全称', required: false }) + shipping_full_address?: string; + + @ApiProperty({ description: '支付方式' }) + payment_method: string; + refunds: UnifiedOrderRefundDTO[]; + @ApiProperty({ description: '创建时间' }) + date_created: string; + + @ApiProperty({ description: '更新时间', required: false }) + date_modified?: string; + + @ApiProperty({ description: '原始数据', type: 'object', required: false }) + raw?: Record; +} + +export class UnifiedCustomerDTO { + // 客户DTO用于承载统一客户数据 + @ApiProperty({ description: '客户ID' }) + id: string | number; + + @ApiProperty({ description: '头像URL', required: false }) + avatar?: string; + + @ApiProperty({ description: '邮箱' }) + email: string; + + @ApiProperty({ description: '订单总数', required: false }) + orders?: number; + + @ApiProperty({ description: '总花费', required: false }) + total_spend?: number; + + @ApiProperty({ description: '创建时间', required: false }) + date_created?: string; + + @ApiProperty({ description: '更新时间', required: false }) + date_modified?: string; + + @ApiProperty({ description: '名', required: false }) + first_name?: string; + + @ApiProperty({ description: '姓', required: false }) + last_name?: string; + + @ApiProperty({ description: '名字', required: false }) + fullname?: string; + + @ApiProperty({ description: '用户名', required: false }) + username?: string; + + @ApiProperty({ description: '电话', required: false }) + phone?: string; + + @ApiProperty({ + description: '账单地址', + type: () => UnifiedAddressDTO, + required: false, + }) + billing?: UnifiedAddressDTO; + + @ApiProperty({ + description: '收货地址', + type: () => UnifiedAddressDTO, + required: false, + }) + shipping?: UnifiedAddressDTO; + + @ApiProperty({ description: '原始数据', type: 'object', required: false }) + raw?: Record; +} + +export class UnifiedSubscriptionDTO { + // 订阅DTO用于承载统一订阅数据 + @ApiProperty({ description: '订阅ID' }) + id: string | number; + + @ApiProperty({ description: '订阅状态' }) + status: string; + + @ApiProperty({ description: '客户ID' }) + customer_id: number; + + @ApiProperty({ description: '计费周期' }) + billing_period: string; + + @ApiProperty({ description: '计费间隔' }) + billing_interval: number; + + @ApiProperty({ description: '创建时间', required: false }) + date_created?: string; + + @ApiProperty({ description: '更新时间', required: false }) + date_modified?: string; + + @ApiProperty({ description: '开始时间' }) + start_date: string; + + @ApiProperty({ description: '下次支付时间' }) + next_payment_date: string; + + @ApiProperty({ description: '订单项', type: () => [UnifiedOrderLineItemDTO] }) + line_items: UnifiedOrderLineItemDTO[]; + + @ApiProperty({ description: '原始数据', type: 'object', required: false }) + raw?: Record; +} + +export class UnifiedMediaDTO { + // 媒体DTO用于承载统一媒体数据 + @ApiProperty({ description: '媒体ID' }) + id: number; + + @ApiProperty({ description: '标题' }) + title: string; + + @ApiProperty({ description: '媒体类型' }) + media_type: string; + + @ApiProperty({ description: 'MIME类型' }) + mime_type: string; + + @ApiProperty({ description: '源URL' }) + source_url: string; + + @ApiProperty({ description: '创建时间' }) + date_created: string; + + @ApiProperty({ description: '更新时间', required: false }) + date_modified?: string; +} + +export class UnifiedProductPaginationDTO extends UnifiedPaginationDTO { + // 产品分页DTO用于承载产品列表分页数据 + @ApiProperty({ description: '列表数据', type: () => [UnifiedProductDTO] }) + items: UnifiedProductDTO[]; +} + +export class UnifiedOrderPaginationDTO extends UnifiedPaginationDTO { + // 订单分页DTO用于承载订单列表分页数据 + @ApiProperty({ description: '列表数据', type: () => [UnifiedOrderDTO] }) + items: UnifiedOrderDTO[]; +} + +export class UnifiedCustomerPaginationDTO extends UnifiedPaginationDTO { + // 客户分页DTO用于承载客户列表分页数据 + @ApiProperty({ description: '列表数据', type: () => [UnifiedCustomerDTO] }) + items: UnifiedCustomerDTO[]; +} + +export class UnifiedSubscriptionPaginationDTO extends UnifiedPaginationDTO { + // 订阅分页DTO用于承载订阅列表分页数据 + @ApiProperty({ description: '列表数据', type: () => [UnifiedSubscriptionDTO] }) + items: UnifiedSubscriptionDTO[]; +} + +export class UnifiedMediaPaginationDTO extends UnifiedPaginationDTO { + // 媒体分页DTO用于承载媒体列表分页数据 + @ApiProperty({ description: '列表数据', type: () => [UnifiedMediaDTO] }) + items: UnifiedMediaDTO[]; +} + +export class UnifiedReviewDTO { + // 评论DTO用于承载统一评论数据 + @ApiProperty({ description: '评论ID' }) + id: number | string; + + @ApiProperty({ description: '产品ID' }) + product_id: number | string; + + @ApiProperty({ description: '评论者' }) + author: string; + + @ApiProperty({ description: '评论者邮箱' }) + email: string; + + @ApiProperty({ description: '评论内容' }) + content: string; + + @ApiProperty({ description: '评分' }) + rating: number; + + @ApiProperty({ description: '状态' }) + status: string; + + @ApiProperty({ description: '创建时间' }) + date_created: string; + + @ApiProperty({ description: '更新时间', required: false }) + date_modified?: string; + + @ApiProperty({ description: '原始数据', type: 'object', required: false }) + raw?: Record; +} + +export class UnifiedReviewPaginationDTO extends UnifiedPaginationDTO { + // 评论分页DTO用于承载评论列表分页数据 + @ApiProperty({ description: '列表数据', type: () => [UnifiedReviewDTO] }) + items: UnifiedReviewDTO[]; +} + +export class CreateReviewDTO { + @ApiProperty({ description: '产品ID' }) + product_id: number | string; + + @ApiProperty({ description: '评论内容' }) + review: string; + + @ApiProperty({ description: '评论者' }) + reviewer: string; + + @ApiProperty({ description: '评论者邮箱' }) + reviewer_email: string; + + @ApiProperty({ description: '评分' }) + rating: number; +} + +export class UpdateReviewDTO { + @ApiProperty({ description: '评论内容', required: false }) + review?: string; + + @ApiProperty({ description: '评分', required: false }) + rating?: number; + + @ApiProperty({ description: '状态', required: false }) + status?: string; +} + +export class UploadMediaDTO { + @ApiProperty({ description: 'Base64 编码的文件内容' }) + file: string; + + @ApiProperty({ description: '文件名' }) + filename: string; +} + +export class UnifiedSearchParamsDTO { + // 统一查询参数DTO用于承载分页与筛选与排序参数 + @ApiProperty({ description: '页码', example: 1, required: false }) + page?: number; + + @ApiProperty({ description: '每页数量', example: 20, required: false }) + per_page?: number; + + @ApiProperty({ description: '每页数量别名', example: 20, required: false }) + page_size?: number; + + @ApiProperty({ description: '搜索关键词', required: false }) + search?: string; + + @ApiProperty({ description: '状态', required: false }) + status?: string; + + @ApiProperty({ description: '客户ID,用于筛选订单', required: false }) + customer_id?: number; + + @ApiProperty({ description: '过滤条件对象', type: 'object', required: false }) + where?: Record; + + @ApiProperty({ + description: '排序对象,例如 { "sku": "desc" }', + type: 'object', + required: false, + }) + order?: Record | string; + + @ApiProperty({ description: '排序字段(兼容旧入参)', required: false }) + orderby?: string; + + @ApiProperty({ description: '排序方式(兼容旧入参)', required: false }) + orderDir?: 'asc' | 'desc'; + + @ApiProperty({ description: '选中ID列表,逗号分隔', required: false }) + ids?: string; +} + +export class UnifiedWebhookDTO { + // Webhook DTO用于承载统一webhook数据 + @ApiProperty({ description: 'Webhook ID' }) + id: number | string; + + @ApiProperty({ description: '名称' }) + name?: string; + + @ApiProperty({ description: '状态' }) + status: string; + + @ApiProperty({ description: '主题/事件' }) + topic: string; + + @ApiProperty({ description: '目标URL' }) + delivery_url: string; + + @ApiProperty({ description: '秘密密钥' }) + secret?: string; + + @ApiProperty({ description: '创建时间' }) + date_created?: string; + + @ApiProperty({ description: '更新时间' }) + date_modified?: string; + + @ApiProperty({ description: '头部信息' }) + headers?: Record; + + @ApiProperty({ description: 'API版本' }) + api_version?: string; +} + +export class UnifiedWebhookPaginationDTO extends UnifiedPaginationDTO { + // Webhook分页DTO用于承载webhook列表分页数据 + @ApiProperty({ description: '列表数据', type: () => [UnifiedWebhookDTO] }) + items: UnifiedWebhookDTO[]; +} + +export class CreateWebhookDTO { + // 创建Webhook DTO + @ApiProperty({ description: '名称' }) + name?: string; + + @ApiProperty({ description: '主题/事件' }) + topic: string; + + @ApiProperty({ description: '目标URL' }) + delivery_url: string; + + @ApiProperty({ description: '秘密密钥' }) + secret?: string; + + @ApiProperty({ description: '头部信息' }) + headers?: Record; + + @ApiProperty({ description: 'API版本' }) + api_version?: string; +} + +export class UpdateWebhookDTO { + // 更新Webhook DTO + @ApiProperty({ description: '名称', required: false }) + name?: string; + + @ApiProperty({ description: '状态', required: false }) + status?: string; + + @ApiProperty({ description: '主题/事件', required: false }) + topic?: string; + + @ApiProperty({ description: '目标URL', required: false }) + delivery_url?: string; + + @ApiProperty({ description: '秘密密钥', required: false }) + secret?: string; + + @ApiProperty({ description: '头部信息', required: false }) + headers?: Record; + + @ApiProperty({ description: 'API版本', required: false }) + api_version?: string; +} + +export class UnifiedOrderRefundDTO { + @ApiProperty({ description: '退款ID' }) + id: number | string; + + @ApiProperty({ description: '退款原因' }) + reason: string; + + @ApiProperty({ description: '退款金额' }) + total: string; +} + +export class ShipOrderItemDTO { + @ApiProperty({ description: '订单项ID' }) + order_item_id: number; + + @ApiProperty({ description: '数量' }) + quantity: number; +} + +export class ShipOrderDTO { + @ApiProperty({ description: '物流单号', required: false }) + tracking_number?: string; + + @ApiProperty({ description: '物流公司', required: false }) + shipping_provider?: string; + + @ApiProperty({ description: '发货方式', required: false }) + shipping_method?: string; + + @ApiProperty({ description: '发货商品项', type: () => [ShipOrderItemDTO], required: false }) + items?: ShipOrderItemDTO[]; +} + +export class CancelShipOrderDTO { + @ApiProperty({ description: '取消原因', required: false }) + reason?: string; + + @ApiProperty({ description: '发货单ID', required: false }) + shipment_id?: string; +} + +export class BatchShipOrderItemDTO { + @ApiProperty({ description: '订单ID' }) + order_id: string; + + @ApiProperty({ description: '物流单号', required: false }) + tracking_number?: string; + + @ApiProperty({ description: '物流公司', required: false }) + shipping_provider?: string; + + @ApiProperty({ description: '发货方式', required: false }) + shipping_method?: string; + + @ApiProperty({ description: '发货商品项', type: () => [ShipOrderItemDTO], required: false }) + items?: ShipOrderItemDTO[]; +} + +export class BatchShipOrdersDTO { + @ApiProperty({ description: '批量发货订单列表', type: () => [BatchShipOrderItemDTO] }) + orders: BatchShipOrderItemDTO[]; +} \ No newline at end of file diff --git a/src/dto/site.dto.ts b/src/dto/site.dto.ts index 17fcdd1..b45a3e3 100644 --- a/src/dto/site.dto.ts +++ b/src/dto/site.dto.ts @@ -20,7 +20,11 @@ export class SiteConfig { @ApiProperty({ description: '站点名' }) @Rule(RuleType.string()) - siteName: string; + name: string; + + @ApiProperty({ description: '描述' }) + @Rule(RuleType.string().allow('').optional()) + description?: string; @ApiProperty({ description: '平台类型', enum: ['woocommerce', 'shopyy'] }) @Rule(RuleType.string().valid('woocommerce', 'shopyy')) @@ -35,15 +39,31 @@ export class CreateSiteDTO { @Rule(RuleType.string().optional()) apiUrl?: string; @Rule(RuleType.string().optional()) + websiteUrl?: string; + @Rule(RuleType.string().optional()) consumerKey?: string; @Rule(RuleType.string().optional()) consumerSecret?: string; + @Rule(RuleType.string().optional()) + token?: string; @Rule(RuleType.string()) - siteName: string; + name: string; + @Rule(RuleType.string().allow('').optional()) + description?: string; @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) type?: string; @Rule(RuleType.string().optional()) skuPrefix?: string; + + // 区域 + @ApiProperty({ description: '区域' }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + areas?: string[]; + + // 绑定仓库 + @ApiProperty({ description: '绑定仓库ID列表' }) + @Rule(RuleType.array().items(RuleType.number()).optional()) + stockPointIds?: number[]; } export class UpdateSiteDTO { @@ -54,13 +74,30 @@ export class UpdateSiteDTO { @Rule(RuleType.string().optional()) consumerSecret?: string; @Rule(RuleType.string().optional()) - siteName?: string; + token?: string; + @Rule(RuleType.string().optional()) + name?: string; + @Rule(RuleType.string().allow('').optional()) + description?: string; @Rule(RuleType.boolean().optional()) isDisabled?: boolean; @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) type?: string; @Rule(RuleType.string().optional()) skuPrefix?: string; + + // 区域 + @ApiProperty({ description: '区域' }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + areas?: string[]; + + // 绑定仓库 + @ApiProperty({ description: '绑定仓库ID列表' }) + @Rule(RuleType.array().items(RuleType.number()).optional()) + stockPointIds?: number[]; + @ApiProperty({ description: '站点网站URL' }) + @Rule(RuleType.string().optional()) + websiteUrl?: string; } export class QuerySiteDTO { diff --git a/src/dto/statistics.dto.ts b/src/dto/statistics.dto.ts index b6cf9dc..31e10a8 100644 --- a/src/dto/statistics.dto.ts +++ b/src/dto/statistics.dto.ts @@ -33,8 +33,4 @@ export class OrderStatisticsParams { @ApiProperty({ enum: ['all', 'zyn', 'yoone', 'zolt'], default: 'all' }) @Rule(RuleType.string().valid('all', 'zyn', 'yoone', 'zolt')) brand: string; - - @ApiProperty({ enum: ['day', 'week', 'month'], default: 'day' }) - @Rule(RuleType.string().valid('day', 'week', 'month')) - grouping: string; } diff --git a/src/dto/stock.dto.ts b/src/dto/stock.dto.ts index 24cfdd8..54629a0 100644 --- a/src/dto/stock.dto.ts +++ b/src/dto/stock.dto.ts @@ -20,7 +20,19 @@ export class QueryStockDTO { @ApiProperty() @Rule(RuleType.string()) - productName: string; + name: string; + + @ApiProperty() + @Rule(RuleType.string()) + sku: string; + + @ApiProperty({ description: '按库存点ID排序', required: false }) + @Rule(RuleType.number().allow(null)) + sortPointId?: number; + + @ApiProperty({ description: '排序对象,格式如 { productName: "asc", sku: "desc" }', required: false }) + @Rule(RuleType.object().allow(null)) + order?: Record; } export class QueryPointDTO { @ApiProperty({ example: '1', description: '页码' }) @@ -46,11 +58,11 @@ export class QueryStockRecordDTO { @ApiProperty() @Rule(RuleType.string()) - productSku: string; + sku: string; @ApiProperty() @Rule(RuleType.string()) - productName: string; + name: string; @ApiProperty() @Rule(RuleType.string()) @@ -84,7 +96,7 @@ export class QueryPurchaseOrderDTO { export class StockDTO extends Stock { @ApiProperty() @Rule(RuleType.string()) - productName: string; + name: string; @ApiProperty({ type: 'object', @@ -120,7 +132,7 @@ export class UpdateStockDTO { @ApiProperty() @Rule(RuleType.string()) - productSku: string; + sku: string; @ApiProperty() @Rule(RuleType.number()) @@ -155,6 +167,19 @@ export class CreateStockPointDTO { @ApiProperty() @Rule(RuleType.string()) contactPhone: string; + + // 区域 + @ApiProperty({ description: '区域' }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + areas?: string[]; + + @ApiProperty({ description: '上游仓库点ID' }) + @Rule(RuleType.number().optional()) + upStreamStockPointId?: number; + + @ApiProperty({ description: '上游名称' }) + @Rule(RuleType.string().optional()) + upStreamName?: string; } export class UpdateStockPointDTO extends CreateStockPointDTO {} diff --git a/src/dto/subscription.dto.ts b/src/dto/subscription.dto.ts index 79794db..4276ec1 100644 --- a/src/dto/subscription.dto.ts +++ b/src/dto/subscription.dto.ts @@ -2,9 +2,9 @@ import { ApiProperty } from '@midwayjs/swagger'; import { Rule, RuleType } from '@midwayjs/validate'; import { SubscriptionStatus } from '../enums/base.enum'; -// 订阅列表查询参数(分页与筛选) +// 订阅列表查询参数(分页与筛选) export class QuerySubscriptionDTO { - // 当前页码(从 1 开始) + // 当前页码(从 1 开始) @ApiProperty({ example: 1, description: '页码' }) @Rule(RuleType.number().default(1)) current: number; @@ -14,23 +14,23 @@ export class QuerySubscriptionDTO { @Rule(RuleType.number().default(10)) pageSize: number; - // 站点 ID(可选) + // 站点 ID(可选) @ApiProperty({ description: '站点ID' }) @Rule(RuleType.string().allow('')) siteId: string; - // 订阅状态筛选(可选),支持枚举值 + // 订阅状态筛选(可选),支持枚举值 @ApiProperty({ description: '订阅状态', enum: SubscriptionStatus }) @Rule(RuleType.string().valid(...Object.values(SubscriptionStatus)).allow('')) status: SubscriptionStatus | ''; - // 客户邮箱(模糊匹配,可选) + // 客户邮箱(模糊匹配,可选) @ApiProperty({ description: '客户邮箱' }) @Rule(RuleType.string().allow('')) customer_email: string; - // 关键字(订阅ID、邮箱等,模糊匹配,可选) - @ApiProperty({ description: '关键字(订阅ID、邮箱等)' }) + // 关键字(订阅ID,邮箱等,模糊匹配,可选) + @ApiProperty({ description: '关键字(订阅ID,邮箱等)' }) @Rule(RuleType.string().allow('')) keyword: string; } \ No newline at end of file diff --git a/src/dto/template.dto.ts b/src/dto/template.dto.ts new file mode 100644 index 0000000..51ff636 --- /dev/null +++ b/src/dto/template.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@midwayjs/swagger'; +import { Rule, RuleType } from '@midwayjs/validate'; + +export class CreateTemplateDTO { + @ApiProperty({ description: '模板名称', required: true }) + @Rule(RuleType.string().required()) + name: string; + + @ApiProperty({ description: '模板内容', required: true }) + @Rule(RuleType.string().required()) + value: string; + + @ApiProperty({ description: '测试数据JSON', required: false }) + @Rule(RuleType.string().optional()) + testData?: string; +} + +export class UpdateTemplateDTO { + @ApiProperty({ description: '模板名称', required: true }) + @Rule(RuleType.string().required()) + name: string; + + @ApiProperty({ description: '模板内容', required: true }) + @Rule(RuleType.string().required()) + value: string; + + @ApiProperty({ description: '测试数据JSON', required: false }) + @Rule(RuleType.string().optional()) + testData?: string; +} + +export class RenderTemplateDTO { + @ApiProperty({ description: '模板内容', required: true }) + @Rule(RuleType.string().required()) + template: string; + + @ApiProperty({ description: '渲染数据', required: true }) + @Rule(RuleType.object().required()) + data: Record; +} diff --git a/src/dto/woocommerce.dto.ts b/src/dto/woocommerce.dto.ts new file mode 100644 index 0000000..044afe1 --- /dev/null +++ b/src/dto/woocommerce.dto.ts @@ -0,0 +1,554 @@ +// WooCommerce 平台原始数据类型定义 +// 仅包含当前映射逻辑所需字段以保持简洁与类型安全 + +// 产品类型 +export interface WooProduct { + // 产品主键 + id: number; + // 创建时间 + date_created: string; + // 创建时间(GMT) + date_created_gmt: string; + // 更新时间 + date_modified: string; + // 更新时间(GMT) + date_modified_gmt: string; + // 产品类型 simple grouped external variable + type: string; + // 产品状态 draft pending private publish + status: string; + // 是否为特色产品 + featured: boolean; + // 目录可见性选项:visible, catalog, search and hidden. Default is visible. + catalog_visibility: string; + + // 常规价格 + regular_price?: string; + // 促销价格 + sale_price?: string; + // 当前价格 + price?: string; + price_html?: string; + date_on_sale_from?: string; // Date the product is on sale from. + date_on_sale_from_gmt?: string; // Date the product is on sale from (GMT). + date_on_sale_to?: string; // Date the product is on sale to. + date_on_sale_to_gmt?: string; // Date the product is on sale to (GMT). + on_sale: boolean; // Whether the product is on sale. + purchasable: boolean; // Whether the product is purchasable. + total_sales: number; // Total sales for this product. + virtual: boolean; // Whether the product is virtual. + downloadable: boolean; // Whether the product is downloadable. + downloads: Array<{ id?: number; name?: string; file?: string }>; // Downloadable files for the product. + download_limit: number; // Download limit. + download_expiry: number; // Download expiry days. + external_url: string; // URL of the external product. + + global_unique_id: string; // GTIN, UPC, EAN or ISBN - a unique identifier for each distinct product and service that can be purchased. + // 产品SKU + sku: string; + // 产品名称 + name: string; + // 产品描述 + description: string; + // 产品短描述 + short_description: string; + + // 产品永久链接 + permalink: string; + // 产品URL路径 + slug: string; + + // 库存状态 + stock_status?: 'instock' | 'outofstock' | 'onbackorder'; + // 库存数量 + stock_quantity?: number; + // 是否管理库存 + manage_stock?: boolean; + // 缺货预定设置 no notify yes + backorders?: 'no' | 'notify' | 'yes'; + // 是否允许缺货预定 只读 + backorders_allowed?: boolean; + // 是否处于缺货预定状态 只读 + backordered?: boolean; + // 是否单独出售 + sold_individually?: boolean; + // 重量 + weight?: string; + // 尺寸 + dimensions?: { length?: string; width?: string; height?: string }; + // 是否需要运输 只读 + shipping_required?: boolean; + // 运输是否计税 只读 + shipping_taxable?: boolean; + // 运输类别 slug + shipping_class?: string; + // 运输类别ID 只读 + shipping_class_id?: number; + // 图片列表 + images?: Array<{ id: number; src: string; name?: string; alt?: string }>; + // 属性列表 + attributes?: Array<{ + id?: number; + name?: string; + position?: number; + visible?: boolean; + variation?: boolean; + options?: string[]; + }>; + // 变体列表 + variations?: number[]; + // 默认变体属性 + default_attributes?: Array<{ id?: number; name?: string; option?: string }>; + // 允许评论 + reviews_allowed?: boolean; + // 平均评分 只读 + average_rating?: string; + // 评分数量 只读 + rating_count?: number; + // 相关产品ID列表 只读 + related_ids?: number[]; + // 追加销售产品ID列表 + upsell_ids?: number[]; + // 交叉销售产品ID列表 + cross_sell_ids?: number[]; + // 父产品ID + parent_id?: number; + // 购买备注 + purchase_note?: string; + // 分类列表 + categories?: Array<{ id: number; name?: string; slug?: string }>; + // 标签列表 + tags?: Array<{ id: number; name?: string; slug?: string }>; + // 菜单排序 + menu_order?: number; + // 元数据 + meta_data?: Array<{ id?: number; key: string; value: any }>; +} + +// 订单类型 +export interface WooOrder { + // 订单主键 + id: number; + // 父订单ID + parent_id?: number; + // 订单号 + number: string; + // 订单键 只读 + order_key?: string; + // 创建来源 + created_via?: string; + // WooCommerce版本 只读 + version?: string; + // 状态 + status: string; + // 币种 + currency: string; + // 价格是否含税 只读 + prices_include_tax?: boolean; + // 总金额 + total: string; + // 总税额 只读 + total_tax?: string; + // 折扣总额 只读 + discount_total?: string; + // 折扣税额 只读 + discount_tax?: string; + // 运费总额 只读 + shipping_total?: string; + // 运费税额 只读 + shipping_tax?: string; + // 购物车税额 只读 + cart_tax?: string; + // 客户ID + customer_id: number; + // 客户IP 只读 + customer_ip_address?: string; + // 客户UA 只读 + customer_user_agent?: string; + // 客户备注 + customer_note?: string; + // 账单信息 + billing?: { + first_name?: string; + last_name?: string; + email?: string; + company?: string; + address_1?: string; + address_2?: string; + city?: string; + state?: string; + postcode?: string; + country?: string; + phone?: string; + fullname?: string; + }; + // 收货信息 + shipping?: { + first_name?: string; + last_name?: string; + company?: string; + address_1?: string; + address_2?: string; + city?: string; + state?: string; + postcode?: string; + country?: string; + phone?: string; + fullname?: string; + }; + // 订单项 + line_items?: Array<{ + product_id?: number; + variation_id?: number; + quantity?: number; + subtotal?: string; + subtotal_tax?: string; + total?: string; + total_tax?: string; + name?: string; + sku?: string; + price?: number; + meta_data?: Array<{ key: string; value: any }>; + [key: string]: any; + }>; + // 税费行 只读 + tax_lines?: Array<{ + id?: number; + rate_code?: string; + rate_id?: number; + label?: string; + tax_total?: string; + shipping_tax_total?: string; + compound?: boolean; + meta_data?: any[]; + }>; + // 物流费用行 + shipping_lines?: Array<{ + id?: number; + method_title?: string; + method_id?: string; + total?: string; + total_tax?: string; + taxes?: any[]; + meta_data?: any[]; + }>; + // 手续费行 + fee_lines?: Array<{ + id?: number; + name?: string; + tax_class?: string; + tax_status?: string; + total?: string; + total_tax?: string; + taxes?: any[]; + meta_data?: any[]; + }>; + // 优惠券行 + coupon_lines?: Array<{ + id?: number; + code?: string; + discount?: string; + discount_tax?: string; + meta_data?: any[]; + }>; + // 退款列表 只读 + refunds?: Array; + // 支付方式标题 + payment_method_title?: string; + // 支付方式ID + payment_method?: string; + // 交易ID + transaction_id?: string; + // 已支付时间 + date_paid?: string; + date_paid_gmt?: string; + // 完成时间 + date_completed?: string; + date_completed_gmt?: string; + // 购物车hash 只读 + cart_hash?: string; + // 设置为已支付 写入专用 + set_paid?: boolean; + // 元数据 + meta_data?: Array<{ id?: number; key: string; value: any }>; + // 创建与更新时间 + date_created: string; + date_created_gmt?: string; + date_modified?: string; + date_modified_gmt?: string; +} +export interface WooOrderRefund { + id?: number; + reason?: string; + total?: string; +} + +// 订阅类型 +export interface WooSubscription { + // 订阅主键 + id: number; + // 订阅状态 + status: string; + // 客户ID + customer_id: number; + // 计费周期 + billing_period?: string; + // 计费间隔 + billing_interval?: number; + // 开始时间 + start_date?: string; + // 下次支付时间 + next_payment_date?: string; + // 订阅项 + line_items?: any[]; + // 创建时间 + date_created?: string; + // 更新时间 + date_modified?: string; +} + +// WordPress 媒体类型 +export interface WpMedia { + // 媒体主键 + id: number; + // 标题可能为字符串或包含rendered的对象 + title?: { rendered?: string } | string; + // 媒体类型 + media_type?: string; + // MIME类型 + mime_type?: string; + // 源地址 + source_url?: string; + // 创建时间兼容date字段 + date_created?: string; + date?: string; + // 更新时间兼容modified字段 + date_modified?: string; + modified?: string; +} + +// 评论类型 +export interface WooReview { + // 评论ID + id: number; + // 评论内容 + review: string; + // 评分 + rating: number; + // 评论者 + reviewer: string; + // 评论者邮箱 + reviewer_email: string; + // 状态 + status: string; + // 产品ID + product_id: number; + // 创建日期 + date_created: string; + // 更新日期 + +} + +// 客户类型 +export interface WooCustomer { + // 客户主键 + id: number; + // 头像URL + avatar_url?: string; + // 邮箱 + email: string; + // 订单总数 + orders?: number; + // 总花费 + total_spent?: number | string; + // 名 + first_name?: string; + // 姓 + last_name?: string; + // 用户名 + username?: string; + // 角色 只读 + role?: string; + // 密码 写入专用 + password?: string; + // 账单信息 + billing?: { + first_name?: string; + last_name?: string; + email?: string; + company?: string; + phone?: string; + address_1?: string; + address_2?: string; + city?: string; + state?: string; + postcode?: string; + country?: string; + }; + // 收货信息 + shipping?: { + first_name?: string; + last_name?: string; + company?: string; + phone?: string; + address_1?: string; + address_2?: string; + city?: string; + state?: string; + postcode?: string; + country?: string; + }; + // 是否为付费客户 只读 + is_paying_customer?: boolean; + // 元数据 + meta_data?: Array<{ id?: number; key: string; value: any }>; + // 创建时间 + date_created?: string; + date_created_gmt?: string; + // 更新时间 + date_modified?: string; + date_modified_gmt?: string; +} + +// Webhook类型 +export interface WooWebhook { + id: number; + name: string; + status: string; + topic: string; + resource: string; + event: string; + hooks: string; + delivery_url: string; + secret: string; + date_created: string; + date_created_gmt: string; + date_modified: string; + date_modified_gmt: string; + api_version: string; + meta_data?: Array<{ id?: number; key: string; value: any }>; +} + + + +export interface WooOrderSearchParams { + context: WooContext; + page: number; + per_page: number; + search: string; + after: string; + before: string; + modified_after: string; + modified_before: string; + date_are_gmt: boolean; + exclude: string[]; + include: string[]; + offset: number; + order: string; + orderby: string; + parant: string[]; + status: (WooOrderStatusSearchParams)[]; + customer: number; + product: number; + dp: number; + created_via: string; +} + + +export enum WooOrderStatusSearchParams { + pending, + processing, + "on-hold", + completed, + cancelled, + refunded, + failed, + trash, + any +} + + +export interface WooProductSearchParams extends ListParams { + slug: string; + status: string[]; + include_status: string; + exclude_status: string; + type: string; + include_types: string; + exclude_types: string; + sku: string; + featured: boolean; + category: string; + tag: string; + shipping_class: string; + attribute: string; + attribute_term: string; + tax_class: string; + on_sale: boolean; + min_price: string; + max_price: string; + stock_status: string; + virtual: boolean; + downloadable: boolean; +} + +export interface ListParams { + context: WooContext; + page: number; + per_page: number; + search: string; + search_fields: any[]; + after: string; + before: string; + modified_after: string; + modified_before: string; + date_are_gmt: boolean; + exclude: string[]; + include: string[]; + offset: number; + order: string; + orderby: string; + parant: string[]; + parent_exclude: string[]; +} +export enum WooContext { + view, + edit +} +export enum WooProductStatusSearchParams { + any, + draft, + pending, + private, + publish +} + +// 发货相关DTO +export class WooShipOrderItemDTO { + order_item_id: number; + quantity: number; +} + +export class WooShipOrderDTO { + tracking_number?: string; + shipping_provider?: string; + shipping_method?: string; + items?: WooShipOrderItemDTO[]; +} + +export class WooCancelShipOrderDTO { + reason?: string; + shipment_id?: string; +} + +export class WooBatchShipOrderItemDTO { + order_id: string; + tracking_number?: string; + shipping_provider?: string; + shipping_method?: string; + items?: WooShipOrderItemDTO[]; +} + +export class WooBatchShipOrdersDTO { + orders: WooBatchShipOrderItemDTO[]; +} \ No newline at end of file diff --git a/src/dto/wp_product.dto.ts b/src/dto/wp_product.dto.ts index 11e2a7b..48212a3 100644 --- a/src/dto/wp_product.dto.ts +++ b/src/dto/wp_product.dto.ts @@ -13,46 +13,58 @@ export class WpProductDTO extends WpProduct { export class UpdateVariationDTO { @ApiProperty({ description: '产品名称' }) - @Rule(RuleType.string()) - name: string; + @Rule(RuleType.string().optional()) + name?: string; @ApiProperty({ description: 'SKU' }) - @Rule(RuleType.string().allow('')) - sku: string; + @Rule(RuleType.string().allow('').optional()) + sku?: string; @ApiProperty({ description: '常规价格', type: Number }) - @Rule(RuleType.number()) - regular_price: number; // 常规价格 + @Rule(RuleType.number().optional()) + regular_price?: number; // 常规价格 @ApiProperty({ description: '销售价格', type: Number }) - @Rule(RuleType.number()) - sale_price: number; // 销售价格 + @Rule(RuleType.number().optional()) + sale_price?: number; // 销售价格 @ApiProperty({ description: '是否促销中', type: Boolean }) - @Rule(RuleType.boolean()) - on_sale: boolean; // 是否促销中 + @Rule(RuleType.boolean().optional()) + on_sale?: boolean; // 是否促销中 } export class UpdateWpProductDTO { @ApiProperty({ description: '变体名称' }) - @Rule(RuleType.string()) - name: string; + @Rule(RuleType.string().optional()) + name?: string; @ApiProperty({ description: 'SKU' }) - @Rule(RuleType.string().allow('')) - sku: string; + @Rule(RuleType.string().allow('').optional()) + sku?: string; @ApiProperty({ description: '常规价格', type: Number }) - @Rule(RuleType.number()) - regular_price: number; // 常规价格 + @Rule(RuleType.number().optional()) + regular_price?: number; // 常规价格 @ApiProperty({ description: '销售价格', type: Number }) - @Rule(RuleType.number()) - sale_price: number; // 销售价格 + @Rule(RuleType.number().optional()) + sale_price?: number; // 销售价格 @ApiProperty({ description: '是否促销中', type: Boolean }) - @Rule(RuleType.boolean()) - on_sale: boolean; // 是否促销中 + @Rule(RuleType.boolean().optional()) + on_sale?: boolean; // 是否促销中 + + @ApiProperty({ description: '分类列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + categories?: string[]; + + @ApiProperty({ description: '标签列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + tags?: string[]; + + @ApiProperty({ description: '站点ID', required: false }) + @Rule(RuleType.number().optional()) + siteId?: number; } export class QueryWpProductDTO { @@ -75,24 +87,50 @@ export class QueryWpProductDTO { @ApiProperty({ description: '产品状态', enum: ProductStatus }) @Rule(RuleType.string().valid(...Object.values(ProductStatus))) status?: ProductStatus; + + @ApiProperty({ description: 'SKU列表', type: Array }) + @Rule(RuleType.array().items(RuleType.string()).single()) + skus?: string[]; } -export class SetConstitutionDTO { - @ApiProperty({ type: Boolean }) - @Rule(RuleType.boolean()) - isProduct: boolean; - - @ApiProperty({ - description: '构成成分', - type: 'array', - items: { - type: 'object', - properties: { - sku: { type: 'string' }, - quantity: { type: 'number' }, - }, - }, - }) - @Rule(RuleType.array()) - constitution: { sku: string; quantity: number }[] | null; +export class BatchSyncProductsDTO { + @ApiProperty({ description: '产品ID列表', type: [Number] }) + @Rule(RuleType.array().items(RuleType.number()).required()) + productIds: number[]; +} + +export class BatchUpdateTagsDTO { + @ApiProperty({ description: '产品ID列表', type: [Number] }) + @Rule(RuleType.array().items(RuleType.number()).required()) + ids: number[]; + + @ApiProperty({ description: '标签列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string()).required()) + tags: string[]; +} + +export class BatchUpdateProductsDTO { + @ApiProperty({ description: '产品ID列表', type: [Number] }) + @Rule(RuleType.array().items(RuleType.number()).required()) + ids: number[]; + + @ApiProperty({ description: '常规价格', type: Number }) + @Rule(RuleType.number()) + regular_price?: number; + + @ApiProperty({ description: '销售价格', type: Number }) + @Rule(RuleType.number()) + sale_price?: number; + + @ApiProperty({ description: '分类列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string())) + categories?: string[]; + + @ApiProperty({ description: '标签列表', type: [String] }) + @Rule(RuleType.array().items(RuleType.string())) + tags?: string[]; + + @ApiProperty({ description: '状态', enum: ProductStatus }) + @Rule(RuleType.string().valid(...Object.values(ProductStatus))) + status?: ProductStatus; } diff --git a/src/entity/area.entity.ts b/src/entity/area.entity.ts new file mode 100644 index 0000000..e66de47 --- /dev/null +++ b/src/entity/area.entity.ts @@ -0,0 +1,17 @@ + +import { ApiProperty } from '@midwayjs/swagger'; +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +@Entity('area') +export class Area { + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ description: '名称' }) + @Column() + name: string; + + @ApiProperty({ description: '编码' }) + @Column({ unique: true }) + code: string; +} diff --git a/src/entity/category.entity.ts b/src/entity/category.entity.ts index 2e52e8a..c0c86ae 100644 --- a/src/entity/category.entity.ts +++ b/src/entity/category.entity.ts @@ -1,53 +1,39 @@ -import { - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Entity, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm'; import { ApiProperty } from '@midwayjs/swagger'; +import { Product } from './product.entity'; +import { CategoryAttribute } from './category_attribute.entity'; -@Entity() +@Entity('category') export class Category { - @ApiProperty({ - example: '1', - description: '分类 ID', - type: 'number', - required: true, - }) + @ApiProperty({ description: 'ID' }) @PrimaryGeneratedColumn() id: number; - @ApiProperty({ - example: '分类名称', - description: '分类名称', - type: 'string', - required: true, - }) + @ApiProperty({ description: '分类显示名称' }) @Column() + title: string; + + @ApiProperty({ description: '分类中文名称' }) + @Column({ nullable: true }) + titleCN: string; + + @ApiProperty({ description: '分类唯一标识' }) + @Column({ unique: true }) name: string; - @ApiProperty({ - description: '唯一识别key', - type: 'string', - required: true, - }) - @Column() - unique_key: string; + @ApiProperty({ description: '排序' }) + @Column({ default: 0 }) + sort: number; + + @OneToMany(() => Product, product => product.category) + products: Product[]; + + @OneToMany(() => CategoryAttribute, categoryAttribute => categoryAttribute.category) + attributes: CategoryAttribute[]; - @ApiProperty({ - example: '2022-12-12 11:11:11', - description: '创建时间', - required: true, - }) @CreateDateColumn() createdAt: Date; - @ApiProperty({ - example: '2022-12-12 11:11:11', - description: '更新时间', - required: true, - }) @UpdateDateColumn() updatedAt: Date; } diff --git a/src/entity/category_attribute.entity.ts b/src/entity/category_attribute.entity.ts new file mode 100644 index 0000000..830c087 --- /dev/null +++ b/src/entity/category_attribute.entity.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Category } from './category.entity'; +import { Dict } from './dict.entity'; +import { ApiProperty } from '@midwayjs/swagger'; + +@Entity() +export class CategoryAttribute { + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ description: '分类' }) + @ManyToOne(() => Category, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'category_id' }) + category: Category; + + @ApiProperty({ description: '关联的属性字典' }) + @ManyToOne(() => Dict, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'attribute_dict_id' }) + attributeDict: Dict; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/dict.entity.ts b/src/entity/dict.entity.ts new file mode 100644 index 0000000..dc0d9af --- /dev/null +++ b/src/entity/dict.entity.ts @@ -0,0 +1,42 @@ +/** + * @description 字典 + * @author ZKS + * @date 2025-11-27 + */ +import { DictItem } from './dict_item.entity'; +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +export class Dict { + // 主键 + @PrimaryGeneratedColumn() + id: number; + + @Column({comment: '字典显示名称'}) + title: string; + // 字典名称 + @Column({ unique: true, comment: '字典名称' }) + name: string; + + // 字典项 + @OneToMany(() => DictItem, item => item.dict) + items: DictItem[]; + + // 是否可删除 + @Column({ default: true, comment: '是否可删除' }) + deletable: boolean; + // 创建时间 + @CreateDateColumn() + createdAt: Date; + + // 更新时间 + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/dict_item.entity.ts b/src/entity/dict_item.entity.ts new file mode 100644 index 0000000..ffa6896 --- /dev/null +++ b/src/entity/dict_item.entity.ts @@ -0,0 +1,67 @@ +/** + * @description 字典项 + * @author ZKS + * @date 2025-11-27 + */ +import { Dict } from './dict.entity'; +import { Product } from './product.entity'; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +@Index(['name', 'dict'], { unique: true }) +export class DictItem { + // 主键 + @PrimaryGeneratedColumn() + id: number; + + // 字典项名称 + @Column({ comment: '字典项显示名称' }) + title: string; + // 目前没有单独做国际化, 所以这里先添加 titleCN 用来标注 + @Column({ comment: '字典项中文名称', nullable: true }) + titleCN: string; + // 唯一标识 + @Column({ comment: '字典唯一标识名称' }) + name: string; + + // 字典项值 + @Column({ nullable: true, comment: '字典项值' }) + value?: string; + + @Column({ nullable: true, comment: '图片' }) + image: string; + + @Column({ nullable: true, comment: '简称' }) + shortName: string; + + // 排序 + @Column({ default: 0, comment: '排序' }) + sort: number; + + // 属于哪个字典 + @ManyToOne(() => Dict, dict => dict.items) + @JoinColumn({ name: 'dict_id' }) + dict: Dict; + + // 关联的产品 + @ManyToMany(() => Product, product => product.attributes) + products: Product[]; + + // 创建时间 + @CreateDateColumn() + createdAt: Date; + + // 更新时间 + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/flavors.entity.ts b/src/entity/flavors.entity.ts deleted file mode 100644 index 9b0cb10..0000000 --- a/src/entity/flavors.entity.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Entity, -} from 'typeorm'; -import { ApiProperty } from '@midwayjs/swagger'; - -@Entity() -export class Flavors { - @ApiProperty() - @PrimaryGeneratedColumn() - id: number; - - @ApiProperty() - @Column() - name: string; - - @ApiProperty({ - description: '唯一识别key', - type: 'string', - required: true, - }) - @Column() - unique_key: string; - - @ApiProperty({ - example: '2022-12-12 11:11:11', - description: '创建时间', - required: true, - }) - @CreateDateColumn() - createdAt: Date; - - @ApiProperty({ - example: '2022-12-12 11:11:11', - description: '更新时间', - required: true, - }) - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/entity/order.entity.ts b/src/entity/order.entity.ts index fc51dd2..09fd126 100644 --- a/src/entity/order.entity.ts +++ b/src/entity/order.entity.ts @@ -24,9 +24,9 @@ export class Order { id: number; @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; // 来源站点唯一标识 + siteId: number; // 来源站点唯一标识 @ApiProperty() @Column() @@ -178,7 +178,7 @@ export class Order { @ApiProperty() @Column({ type: 'mediumtext', // 设置字段类型为 MEDIUMTEXT - nullable: true, // 可选:是否允许为 NULL + nullable: true, // 可选:是否允许为 NULL }) @Expose() customer_note: string; diff --git a/src/entity/order_copon.entity.ts b/src/entity/order_coupon.entity.ts similarity index 95% rename from src/entity/order_copon.entity.ts rename to src/entity/order_coupon.entity.ts index c1a16bd..0670870 100644 --- a/src/entity/order_copon.entity.ts +++ b/src/entity/order_coupon.entity.ts @@ -22,9 +22,9 @@ export class OrderCoupon { orderId: number; // 订单 ID @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; // 来源站点唯一标识 + siteId: number; // 站点ID @ApiProperty() @Column() diff --git a/src/entity/order_fee.entity.ts b/src/entity/order_fee.entity.ts index 5b83850..b7d8888 100644 --- a/src/entity/order_fee.entity.ts +++ b/src/entity/order_fee.entity.ts @@ -22,9 +22,9 @@ export class OrderFee { orderId: number; // 订单 ID @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; + siteId: number; // 站点ID @ApiProperty() @Column() diff --git a/src/entity/order_item.entity.ts b/src/entity/order_item.entity.ts index 0a3fd5b..dca5c62 100644 --- a/src/entity/order_item.entity.ts +++ b/src/entity/order_item.entity.ts @@ -22,9 +22,9 @@ export class OrderItem { name: string; @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; // 来源站点唯一标识 + siteId: number; // 来源站点唯一标识 @ApiProperty() @Column() @@ -79,17 +79,17 @@ export class OrderItem { @ApiProperty() @Column({ nullable: true }) @Expose() - tax_class?: string; // 税类(来自 line_items.tax_class) + tax_class?: string; // 税类(来自 line_items.tax_class) @ApiProperty() @Column({ type: 'json', nullable: true }) @Expose() - taxes?: any[]; // 税明细(来自 line_items.taxes,数组) + taxes?: any[]; // 税明细(来自 line_items.taxes,数组) @ApiProperty() @Column({ type: 'json', nullable: true }) @Expose() - meta_data?: any[]; // 行项目元数据(包含订阅相关键值) + meta_data?: any[]; // 行项目元数据(包含订阅相关键值) @ApiProperty() @Column({ nullable: true }) @@ -99,7 +99,7 @@ export class OrderItem { @ApiProperty() @Column({ nullable: true }) @Expose() - global_unique_id?: string; // 全局唯一ID(部分主题/插件会提供) + global_unique_id?: string; // 全局唯一ID(部分主题/插件会提供) @ApiProperty() @Column('decimal', { precision: 10, scale: 2 }) @@ -109,17 +109,17 @@ export class OrderItem { @ApiProperty() @Column({ type: 'json', nullable: true }) @Expose() - image?: { id?: string | number; src?: string }; // 商品图片(对象,包含 id/src) + image?: { id?: string | number; src?: string }; // 商品图片(对象,包含 id/src) @ApiProperty() @Column({ nullable: true }) @Expose() - parent_name?: string; // 父商品名称(组合/捆绑时可能使用) + parent_name?: string; // 父商品名称(组合/捆绑时可能使用) @ApiProperty() @Column({ nullable: true }) @Expose() - bundled_by?: string; // 捆绑来源标识(bundled_by) + bundled_by?: string; // 捆绑来源标识(bundled_by) @ApiProperty() @Column({ nullable: true }) @@ -129,7 +129,7 @@ export class OrderItem { @ApiProperty() @Column({ type: 'json', nullable: true }) @Expose() - bundled_items?: any[]; // 捆绑项列表(数组) + bundled_items?: any[]; // 捆绑项列表(数组) @ApiProperty({ example: '2022-12-12 11:11:11', diff --git a/src/entity/order_item_original.entity.ts b/src/entity/order_item_original.entity.ts index 26c494a..d7ed055 100644 --- a/src/entity/order_item_original.entity.ts +++ b/src/entity/order_item_original.entity.ts @@ -11,9 +11,9 @@ import { } from 'typeorm'; import { Order } from './order.entity'; -@Entity('order_sale_original') +@Entity('order_item_original') @Exclude() -export class OrderSaleOriginal { +export class OrderItemOriginal { @ApiProperty() @PrimaryGeneratedColumn() @Expose() @@ -27,9 +27,9 @@ export class OrderSaleOriginal { orderId: number; // 订单 ID @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; // 来源站点唯一标识 + siteId: number; // 站点ID @ApiProperty() @Column({ nullable: true }) diff --git a/src/entity/order_refund.entity.ts b/src/entity/order_refund.entity.ts index a9d5251..4b6a869 100644 --- a/src/entity/order_refund.entity.ts +++ b/src/entity/order_refund.entity.ts @@ -22,9 +22,9 @@ export class OrderRefund { orderId: number; // 订单 ID @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; // 来源站点唯一标识 + siteId: number; // 站点ID @ApiProperty() @Column() diff --git a/src/entity/order_retund_item.entity.ts b/src/entity/order_refund_item.entity.ts similarity index 97% rename from src/entity/order_retund_item.entity.ts rename to src/entity/order_refund_item.entity.ts index e148bbf..76cdebc 100644 --- a/src/entity/order_retund_item.entity.ts +++ b/src/entity/order_refund_item.entity.ts @@ -22,9 +22,9 @@ export class OrderRefundItem { refundId: number; // 订单 refund ID @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; // 来源站点唯一标识 + siteId: number; // 站点ID @ApiProperty() @Column() diff --git a/src/entity/order_sale.entity.ts b/src/entity/order_sale.entity.ts index e7958ed..eec088f 100644 --- a/src/entity/order_sale.entity.ts +++ b/src/entity/order_sale.entity.ts @@ -24,9 +24,9 @@ export class OrderSale { orderId: number; // 订单 ID @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; // 来源站点唯一标识 + siteId: number; // 来源站点唯一标识 @ApiProperty() @Column({ nullable: true }) diff --git a/src/entity/order_shipping.entity.ts b/src/entity/order_shipping.entity.ts index f32e85e..8220992 100644 --- a/src/entity/order_shipping.entity.ts +++ b/src/entity/order_shipping.entity.ts @@ -22,9 +22,9 @@ export class OrderShipping { orderId: number; // 订单 ID @ApiProperty() - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; + siteId: number; // 站点ID @ApiProperty() @Column() diff --git a/src/entity/product.entity.ts b/src/entity/product.entity.ts index e328725..59dbfb8 100644 --- a/src/entity/product.entity.ts +++ b/src/entity/product.entity.ts @@ -4,8 +4,17 @@ import { CreateDateColumn, UpdateDateColumn, Entity, + ManyToMany, + JoinTable, + OneToMany, + ManyToOne, + JoinColumn, } from 'typeorm'; import { ApiProperty } from '@midwayjs/swagger'; +import { DictItem } from './dict_item.entity'; +import { ProductStockComponent } from './product_stock_component.entity'; +import { ProductSiteSku } from './product_site_sku.entity'; +import { Category } from './category.entity'; @Entity() export class Product { @@ -17,6 +26,14 @@ export class Product { }) @PrimaryGeneratedColumn() id: number; + + @ApiProperty({ description: 'sku'}) + @Column({ unique: true }) + sku: string; + // 类型 主要用来区分混装和单品 单品死 + @ApiProperty({ description: '类型' }) + @Column({ length: 16, default: 'single' }) + type: string; @ApiProperty({ example: 'ZYN 6MG WINTERGREEN', @@ -27,33 +44,55 @@ export class Product { @Column() name: string; - @ApiProperty() - @Column({ default: ''}) + @ApiProperty({ description: '产品中文名称' }) + @Column({ default: '' }) nameCn: string; - @ApiProperty({ example: '产品描述', description: '产品描述', type: 'string' }) + @ApiProperty({ example: '产品简短描述', description: '产品简短描述' }) + @Column({ nullable: true }) + shortDescription?: string; + + @ApiProperty({ example: '产品描述', description: '产品描述' }) @Column({ nullable: true }) description?: string; - @ApiProperty({ example: '1', description: '分类 ID', type: 'number' }) - @Column() - categoryId: number; + // 商品价格 + @ApiProperty({ description: '价格', example: 99.99 }) + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + price: number; - @ApiProperty() - @Column() - flavorsId: number; + // 促销价格 + @ApiProperty({ description: '促销价格', example: 99.99 }) + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + promotionPrice: number; - @ApiProperty() - @Column() - strengthId: number; - @ApiProperty() - @Column() - humidity: string; - @ApiProperty({ description: 'sku', type: 'string' }) - @Column({ nullable: true }) - sku?: string; + + // 分类关联 + @ManyToOne(() => Category, category => category.products) + @JoinColumn({ name: 'categoryId' }) + category: Category; + + @ManyToMany(() => DictItem, dictItem => dictItem.products, { + cascade: true, + }) + @JoinTable() + attributes: DictItem[]; + + // 产品的库存组成,一对多关系(使用独立表) + @ApiProperty({ description: '库存组成', type: ProductStockComponent, isArray: true }) + @OneToMany(() => ProductStockComponent, (component) => component.product, { cascade: true }) + components: ProductStockComponent[]; + + @ApiProperty({ description: '站点 SKU 列表', type: ProductSiteSku, isArray: true }) + @OneToMany(() => ProductSiteSku, (siteSku) => siteSku.product, { cascade: true }) + siteSkus: ProductSiteSku[]; + + // 来源 + @ApiProperty({ description: '来源', example: '1' }) + @Column({ default: 0 }) + source: number; @ApiProperty({ example: '2022-12-12 11:11:11', diff --git a/src/entity/product_site_sku.entity.ts b/src/entity/product_site_sku.entity.ts new file mode 100644 index 0000000..c91c172 --- /dev/null +++ b/src/entity/product_site_sku.entity.ts @@ -0,0 +1,36 @@ +import { + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Entity, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ApiProperty } from '@midwayjs/swagger'; +import { Product } from './product.entity'; + +@Entity('product_site_sku') +export class ProductSiteSku { + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ description: '站点 SKU' }) + @Column({ length: 100, comment: '站点 SKU' }) + siteSku: string; + + @ManyToOne(() => Product, product => product.siteSkus, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'productId' }) + product: Product; + + @Column() + productId: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/product_stock_component.entity.ts b/src/entity/product_stock_component.entity.ts new file mode 100644 index 0000000..4048070 --- /dev/null +++ b/src/entity/product_stock_component.entity.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@midwayjs/swagger'; +import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity('product_stock_component') +export class ProductStockComponent { + @ApiProperty({ type: Number }) + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ type: Number }) + @Column() + productId: number; + + @ApiProperty({ description: '组件所关联的 SKU', type: 'string' }) + @Column({ type: 'varchar', length: 64 }) + sku: string; + + @ApiProperty({ type: Number, description: '组成数量' }) + @Column({ type: 'int', default: 1 }) + quantity: number; + + // 多对一,组件隶属于一个产品 + @ManyToOne(() => Product, (product) => product.components, { onDelete: 'CASCADE' }) + product: Product; + + @ApiProperty({ description: '创建时间' }) + @CreateDateColumn() + createdAt: Date; + + @ApiProperty({ description: '更新时间' }) + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/purchase_order_item.entity.ts b/src/entity/purchase_order_item.entity.ts index 5144ccd..4d4b52b 100644 --- a/src/entity/purchase_order_item.entity.ts +++ b/src/entity/purchase_order_item.entity.ts @@ -10,11 +10,11 @@ export class PurchaseOrderItem { @ApiProperty({ type: String }) @Column() - productSku: string; + sku: string; @ApiProperty({ type: String }) @Column() - productName: string; + name: string; @ApiProperty({ type: Number }) @Column() diff --git a/src/entity/site.entity.ts b/src/entity/site.entity.ts index d4e3215..7e03f4b 100644 --- a/src/entity/site.entity.ts +++ b/src/entity/site.entity.ts @@ -1,28 +1,47 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { Area } from './area.entity'; +import { StockPoint } from './stock_point.entity'; @Entity('site') export class Site { - @PrimaryGeneratedColumn({ type: 'int' }) + @PrimaryGeneratedColumn() id: number; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ length: 255, nullable: true }) apiUrl: string; - @Column({ type: 'varchar', length: 255, nullable: true }) - consumerKey: string; + @Column({ name: 'website_url', length: 255, nullable: true }) + websiteUrl: string; - @Column({ type: 'varchar', length: 255, nullable: true }) - consumerSecret: string; + @Column({ length: 255, nullable: true }) + consumerKey?: string; - @Column({ type: 'varchar', length: 255, unique: true }) - siteName: string; + @Column({ length: 255, nullable: true }) + consumerSecret?: string; - @Column({ type: 'varchar', length: 32, default: 'woocommerce' }) - type: string; // 平台类型:woocommerce | shopyy + @Column({ nullable: true }) + token?: string; - @Column({ type: 'varchar', length: 64, nullable: true }) + @Column({ length: 255, unique: true }) + name: string; + + @Column({ length: 255, nullable: true }) + description?: string; + + @Column({ length: 32, default: 'woocommerce' }) + type: string; // 平台类型:woocommerce | shopyy + + @Column({ length: 64, nullable: true }) skuPrefix: string; - @Column({ type: 'tinyint', default: 0 }) - isDisabled: number; + @Column({ default: false }) + isDisabled: boolean; + + @ManyToMany(() => Area) + @JoinTable() + areas: Area[]; + + @ManyToMany(() => StockPoint, stockPoint => stockPoint.sites) + @JoinTable() + stockPoints: StockPoint[]; } \ No newline at end of file diff --git a/src/entity/stock.entity.ts b/src/entity/stock.entity.ts index 233cda7..3977830 100644 --- a/src/entity/stock.entity.ts +++ b/src/entity/stock.entity.ts @@ -20,7 +20,7 @@ export class Stock { @ApiProperty({ type: String }) @Column() - productSku: string; + sku: string; @ApiProperty({ type: Number }) @Column() diff --git a/src/entity/stock_point.entity.ts b/src/entity/stock_point.entity.ts index ffb14d6..dda7ea8 100644 --- a/src/entity/stock_point.entity.ts +++ b/src/entity/stock_point.entity.ts @@ -8,8 +8,12 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, OneToMany, + ManyToMany, + JoinTable, } from 'typeorm'; import { Shipment } from './shipment.entity'; +import { Area } from './area.entity'; +import { Site } from './site.entity'; @Entity('stock_point') export class StockPoint extends BaseEntity { @@ -51,7 +55,7 @@ export class StockPoint extends BaseEntity { @Column({ default: 'uniuni' }) upStreamName: string; - @Column() + @Column({ default: 0 }) upStreamStockPointId: number; @ApiProperty({ @@ -72,4 +76,11 @@ export class StockPoint extends BaseEntity { @DeleteDateColumn() deletedAt: Date; // 软删除时间 + + @ManyToMany(() => Area) + @JoinTable() + areas: Area[]; + + @ManyToMany(() => Site, site => site.stockPoints) + sites: Site[]; } diff --git a/src/entity/stock_record.entity.ts b/src/entity/stock_record.entity.ts index 0d48882..bd6cf81 100644 --- a/src/entity/stock_record.entity.ts +++ b/src/entity/stock_record.entity.ts @@ -20,7 +20,7 @@ export class StockRecord { @ApiProperty({ type: String }) @Column() - productSku: string; + sku: string; @ApiProperty({ type: StockRecordOperationType }) @Column({ type: 'enum', enum: StockRecordOperationType }) diff --git a/src/entity/strength.entity.ts b/src/entity/strength.entity.ts deleted file mode 100644 index 9ffc916..0000000 --- a/src/entity/strength.entity.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Entity, -} from 'typeorm'; -import { ApiProperty } from '@midwayjs/swagger'; - -@Entity() -export class Strength { - @ApiProperty() - @PrimaryGeneratedColumn() - id: number; - - @ApiProperty() - @Column() - name: string; - - @ApiProperty({ - description: '唯一识别key', - type: 'string', - required: true, - }) - @Column() - unique_key: string; - - @ApiProperty({ - example: '2022-12-12 11:11:11', - description: '创建时间', - required: true, - }) - @CreateDateColumn() - createdAt: Date; - - @ApiProperty({ - example: '2022-12-12 11:11:11', - description: '更新时间', - required: true, - }) - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/entity/subscription.entity.ts b/src/entity/subscription.entity.ts index f4c76bb..8ec28e2 100644 --- a/src/entity/subscription.entity.ts +++ b/src/entity/subscription.entity.ts @@ -12,68 +12,68 @@ import { SubscriptionStatus } from '../enums/base.enum'; @Entity('subscription') @Exclude() export class Subscription { - // 本地主键,自增 ID + // 本地主键,自增 ID @ApiProperty() @PrimaryGeneratedColumn() @Expose() id: number; - // 站点唯一标识,用于区分不同来源站点 + // 站点唯一标识,用于区分不同来源站点 @ApiProperty({ description: '来源站点唯一标识' }) - @Column() + @Column({ nullable: true }) @Expose() - siteId: string; + siteId: number; - // WooCommerce 订阅的原始 ID(字符串化),用于幂等更新 + // WooCommerce 订阅的原始 ID(字符串化),用于幂等更新 @ApiProperty({ description: 'WooCommerce 订阅 ID' }) @Column() @Expose() externalSubscriptionId: string; - // 订阅状态(active/cancelled/on-hold 等) + // 订阅状态(active/cancelled/on-hold 等) @ApiProperty({ type: SubscriptionStatus }) @Column({ type: 'enum', enum: SubscriptionStatus }) @Expose() status: SubscriptionStatus; - // 货币代码,例如 USD/CAD + // 货币代码,例如 USD/CAD @ApiProperty() @Column({ default: '' }) @Expose() currency: string; - // 总金额,保留两位小数 + // 总金额,保留两位小数 @ApiProperty() @Column('decimal', { precision: 10, scale: 2, default: 0 }) @Expose() total: number; - // 计费周期(day/week/month/year) + // 计费周期(day/week/month/year) @ApiProperty({ description: '计费周期 e.g. day/week/month/year' }) @Column({ default: '' }) @Expose() billing_period: string; - // 计费周期间隔(例如 1/3/12) + // 计费周期间隔(例如 1/3/12) @ApiProperty({ description: '计费周期间隔 e.g. 1/3/12' }) @Column({ type: 'int', default: 0 }) @Expose() billing_interval: number; - // 客户 ID(WooCommerce 用户 ID) + // 客户 ID(WooCommerce 用户 ID) @ApiProperty() @Column({ type: 'int', default: 0 }) @Expose() customer_id: number; - // 客户邮箱(从 billing.email 或 customer_email 提取) + // 客户邮箱(从 billing.email 或 customer_email 提取) @ApiProperty() @Column({ default: '' }) @Expose() customer_email: string; - // 父订单/订阅 ID(如有) - @ApiProperty({ description: '父订单/父订阅ID(如有)' }) + // 父订单/订阅 ID(如有) + @ApiProperty({ description: '父订单/父订阅ID(如有)' }) @Column({ type: 'int', default: 0 }) @Expose() parent_id: number; @@ -102,25 +102,25 @@ export class Subscription { @Expose() end_date: Date; - // 商品项(订阅行项目) + // 商品项(订阅行项目) @ApiProperty() @Column({ type: 'json', nullable: true }) @Expose() line_items: any[]; - // 额外元数据(键值对) + // 额外元数据(键值对) @ApiProperty() @Column({ type: 'json', nullable: true }) @Expose() meta_data: any[]; - // 创建时间(数据库自动生成) + // 创建时间(数据库自动生成) @ApiProperty({ example: '2022-12-12 11:11:11', description: '创建时间', required: true }) @CreateDateColumn() @Expose() createdAt: Date; - // 更新时间(数据库自动生成) + // 更新时间(数据库自动生成) @ApiProperty({ example: '2022-12-12 11:11:11', description: '更新时间', required: true }) @UpdateDateColumn() @Expose() diff --git a/src/entity/template.entity.ts b/src/entity/template.entity.ts new file mode 100644 index 0000000..13fee49 --- /dev/null +++ b/src/entity/template.entity.ts @@ -0,0 +1,54 @@ +import { ApiProperty } from '@midwayjs/swagger'; +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('template') +export class Template { + @ApiProperty({ type: 'number' }) + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ type: 'string' }) + @Column({ unique: true }) + name: string; + + @ApiProperty({ type: 'string' }) + @Column('text') + value: string; + + @ApiProperty({ nullable: true ,name:"描述"}) + @Column('text',{nullable: true,comment: "描述"}) + description?: string; + + @ApiProperty({ type: 'string', nullable: true, description: '测试数据JSON' }) + @Column('text', { nullable: true, comment: '测试数据JSON' }) + testData?: string; + + @ApiProperty({ + example: true, + description: '是否可删除', + required: true, + }) + @Column({ default: true }) + deletable: boolean; + @ApiProperty({ + example: '2022-12-12 11:11:11', + description: '创建时间', + required: true, + }) + @CreateDateColumn() + createdAt: Date; + + @ApiProperty({ + example: '2022-12-12 11:11:11', + description: '更新时间', + required: true, + }) + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/transfer_item.entity.ts b/src/entity/transfer_item.entity.ts index 312f6dd..6c72f54 100644 --- a/src/entity/transfer_item.entity.ts +++ b/src/entity/transfer_item.entity.ts @@ -9,11 +9,11 @@ export class TransferItem { @ApiProperty({ type: String }) @Column() - productSku: string; + sku: string; @ApiProperty({ type: String }) @Column() - productName: string; + name: string; @ApiProperty({ type: Number }) @Column() diff --git a/src/entity/user.entity.ts b/src/entity/user.entity.ts index 754490e..3b4045f 100644 --- a/src/entity/user.entity.ts +++ b/src/entity/user.entity.ts @@ -15,10 +15,14 @@ export class User { password: string; // @Column() // 默认角色为管理员 - // roleId: number; // 角色 (如:admin, editor, viewer) + // roleId: number; // 角色 (如:admin, editor, viewer) @Column({ type: 'simple-array', nullable: true }) - permissions: string[]; // 自定义权限 (如:['user:add', 'user:edit']) + permissions: string[]; // 自定义权限 (如:['user:add', 'user:edit']) + + // 新增邮箱字段,可选且唯一 + @Column({ unique: true, nullable: true }) + email?: string; @Column({ default: false }) isSuper: boolean; // 超级管理员 @@ -28,4 +32,8 @@ export class User { @Column({ default: true }) isActive: boolean; // 用户是否启用 + + // 备注字段(可选) + @Column({ nullable: true }) + remark?: string; } diff --git a/src/entity/variation.entity.ts b/src/entity/variation.entity.ts index 7550bb5..b3232f5 100644 --- a/src/entity/variation.entity.ts +++ b/src/entity/variation.entity.ts @@ -21,13 +21,12 @@ export class Variation { id: number; @ApiProperty({ - example: '1', - description: 'wp网站ID', - type: 'string', - required: true, + description: '站点 id', + example: 1, + required: false }) - @Column() - siteId: string; // 来源站点唯一标识 + @Column({ nullable: true }) + siteId: number; @ApiProperty({ example: '1', @@ -102,18 +101,4 @@ export class Variation { }) @UpdateDateColumn() updatedAt: Date; - - @ApiProperty({ - description: '变体构成成分', - type: 'array', - items: { - type: 'object', - properties: { - sku: { type: 'string' }, - quantity: { type: 'number' }, - }, - }, - }) - @Column('json', { nullable: true, comment: '变体构成成分' }) - constitution: { sku: string; quantity: number }[] | null; } diff --git a/src/entity/wp_product.entity.ts b/src/entity/wp_product.entity.ts index 3cabe1c..b0327db 100644 --- a/src/entity/wp_product.entity.ts +++ b/src/entity/wp_product.entity.ts @@ -1,3 +1,4 @@ +import { Site } from './site.entity'; import { PrimaryGeneratedColumn, Column, @@ -5,6 +6,8 @@ import { UpdateDateColumn, Unique, Entity, + ManyToOne, + JoinColumn, } from 'typeorm'; import { ApiProperty } from '@midwayjs/swagger'; import { ProductStatus, ProductStockStatus, ProductType } from '../enums/base.enum'; @@ -22,13 +25,18 @@ export class WpProduct { id: number; @ApiProperty({ - example: '1', + example: 1, description: 'wp网站ID', - type: 'string', + type: 'number', required: true, }) - @Column() - siteId: string; + @Column({ type: 'int', nullable: true }) + siteId: number; + + @ApiProperty({ description: '站点信息', type: Site }) + @ManyToOne(() => Site) + @JoinColumn({ name: 'siteId', referencedColumnName: 'id' }) + site: Site; @ApiProperty({ example: '1', @@ -39,7 +47,7 @@ export class WpProduct { @Column() externalProductId: string; - @ApiProperty({ description: 'sku', type: 'string' }) + @ApiProperty({ description: '商店sku', type: 'string' }) @Column({ nullable: true }) sku?: string; @@ -53,42 +61,150 @@ export class WpProduct { name: string; @ApiProperty({ description: '产品状态', enum: ProductStatus }) - @Column({ type: 'enum', enum: ProductStatus }) + @Column({ type: 'enum', enum: ProductStatus, comment: '产品状态: draft, pending, private, publish' }) status: ProductStatus; + @ApiProperty({ description: '是否为特色产品', type: 'boolean' }) + @Column({ default: false, comment: '是否为特色产品' }) + featured: boolean; + + @ApiProperty({ description: '目录可见性', type: 'string' }) + @Column({ default: 'visible', comment: '目录可见性: visible, catalog, search, hidden' }) + catalog_visibility: string; + + @ApiProperty({ description: '产品描述', type: 'string' }) + @Column({ type: 'text', nullable: true, comment: '产品描述' }) + description: string; + + @ApiProperty({ description: '产品短描述', type: 'string' }) + @Column({ type: 'text', nullable: true, comment: '产品短描述' }) + short_description: string; + @ApiProperty({ description: '上下架状态', enum: ProductStockStatus }) @Column({ name: 'stock_status', type: 'enum', enum: ProductStockStatus, - default: ProductStockStatus.INSTOCK + default: ProductStockStatus.INSTOCK, + comment: '库存状态: instock, outofstock, onbackorder', }) stockStatus: ProductStockStatus; + @ApiProperty({ description: '库存数量', type: 'number' }) + @Column({ type: 'int', nullable: true, comment: '库存数量' }) + stock_quantity: number; + + @ApiProperty({ description: '允许缺货下单', type: 'string' }) + @Column({ nullable: true, comment: '允许缺货下单: no, notify, yes' }) + backorders: string; + + @ApiProperty({ description: '是否单独出售', type: 'boolean' }) + @Column({ default: false, comment: '是否单独出售' }) + sold_individually: boolean; + @ApiProperty({ description: '常规价格', type: Number }) - @Column('decimal', { precision: 10, scale: 2, nullable: true }) - regular_price: number; // 常规价格 + @Column('decimal', { precision: 10, scale: 2, nullable: true, comment: '常规价格' }) + regular_price: number; @ApiProperty({ description: '销售价格', type: Number }) - @Column('decimal', { precision: 10, scale: 2, nullable: true }) - sale_price: number; // 销售价格 + @Column('decimal', { precision: 10, scale: 2, nullable: true, comment: '销售价格' }) + sale_price: number; + + @ApiProperty({ description: '促销开始日期', type: 'datetime' }) + @Column({ type: 'datetime', nullable: true, comment: '促销开始日期' }) + date_on_sale_from: Date| null; + + @ApiProperty({ description: '促销结束日期', type: 'datetime' }) + @Column({ type: 'datetime', nullable: true, comment: '促销结束日期' }) + date_on_sale_to: Date|null; @ApiProperty({ description: '是否促销中', type: Boolean }) - @Column({ nullable: true, type: Boolean }) - on_sale: boolean; // 是否促销中 + @Column({ nullable: true, type: 'boolean', comment: '是否促销中' }) + on_sale: boolean; - @ApiProperty({ description: '是否删除', type: Boolean }) - @Column({ nullable: true, type: Boolean , default: false }) - on_delete: boolean; // 是否删除 + @ApiProperty({ description: '税务状态', type: 'string' }) + @Column({ default: 'taxable', comment: '税务状态: taxable, shipping, none' }) + tax_status: string; + @ApiProperty({ description: '税类', type: 'string' }) + @Column({ nullable: true, comment: '税类' }) + tax_class: string; - @ApiProperty({ - description: '产品类型', - enum: ProductType, - }) - @Column({ type: 'enum', enum: ProductType }) + @ApiProperty({ description: '重量(g)', type: 'number' }) + @Column('decimal', { precision: 10, scale: 2, nullable: true, comment: '重量(g)' }) + weight: number; + + @ApiProperty({ description: '尺寸(长宽高)', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '尺寸' }) + dimensions: { length: string; width: string; height: string }; + + @ApiProperty({ description: '允许评论', type: 'boolean' }) + @Column({ default: true, comment: '允许客户评论' }) + reviews_allowed: boolean; + + @ApiProperty({ description: '购买备注', type: 'string' }) + @Column({ nullable: true, comment: '购买备注' }) + purchase_note: string; + + @ApiProperty({ description: '菜单排序', type: 'number' }) + @Column({ default: 0, comment: '菜单排序' }) + menu_order: number; + + @ApiProperty({ description: '产品类型', enum: ProductType }) + @Column({ type: 'enum', enum: ProductType, comment: '产品类型: simple, grouped, external, variable' }) type: ProductType; + @ApiProperty({ description: '父产品ID', type: 'number' }) + @Column({ default: 0, comment: '父产品ID' }) + parent_id: number; + + @ApiProperty({ description: '外部产品URL', type: 'string' }) + @Column({ type: 'text', nullable: true, comment: '外部产品URL' }) + external_url: string; + + @ApiProperty({ description: '外部产品按钮文本', type: 'string' }) + @Column({ nullable: true, comment: '外部产品按钮文本' }) + button_text: string; + + @ApiProperty({ description: '分组产品', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '分组产品' }) + grouped_products: number[]; + + @ApiProperty({ description: '追加销售', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '追加销售' }) + upsell_ids: number[]; + + @ApiProperty({ description: '交叉销售', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '交叉销售' }) + cross_sell_ids: number[]; + + @ApiProperty({ description: '分类', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '分类' }) + categories: { id: number; name: string; slug: string }[]; + + @ApiProperty({ description: '标签', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '标签' }) + tags: { id: number; name: string; slug: string }[]; + + @ApiProperty({ description: '图片', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '图片' }) + images: { id: number; src: string; name: string; alt: string }[]; + + @ApiProperty({ description: '产品属性', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '产品属性' }) + attributes: { id: number; name: string; position: number; visible: boolean; variation: boolean; options: string[] }[]; + + @ApiProperty({ description: '默认属性', type: 'json' }) + @Column({ type: 'json', nullable: true, comment: '默认属性' }) + default_attributes: { id: number; name: string; option: string }[]; + + @ApiProperty({ description: 'GTIN', type: 'string' }) + @Column({ nullable: true, comment: 'GTIN, UPC, EAN, or ISBN' }) + gtin: string; + + @ApiProperty({ description: '是否删除', type: 'boolean' }) + @Column({ nullable: true, type: 'boolean', default: false, comment: '是否删除' }) + on_delete: boolean; @Column({ type: 'json', nullable: true }) metadata: Record; // 产品的其他扩展字段 @@ -108,18 +224,4 @@ export class WpProduct { }) @UpdateDateColumn() updatedAt: Date; - - @ApiProperty({ - description: '产品构成成分', - type: 'array', - items: { - type: 'object', - properties: { - sku: { type: 'string' }, - quantity: { type: 'number' }, - }, - }, - }) - @Column('json', { nullable: true, comment: '产品构成成分' }) - constitution: { sku: string; quantity: number }[] | null; } diff --git a/src/enums/base.enum.ts b/src/enums/base.enum.ts index 085a582..95903d3 100644 --- a/src/enums/base.enum.ts +++ b/src/enums/base.enum.ts @@ -42,7 +42,7 @@ export enum OrderStatus { REFUNDED = 'refunded', // 已退款 FAILED = 'failed', // 失败订单 DRAFT = 'draft', // 草稿 - AUTO_DRAFT = 'auto-draft', // 自动草稿 + // TRASH = 'trash', // refund 也就是退款相关的状态 RETURN_REQUESTED = 'return-requested', // 已申请退款 diff --git a/src/interface.ts b/src/interface.ts index f7b94f9..e000403 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -10,7 +10,7 @@ export interface WpSite { wpApiUrl: string; consumerKey: string; consumerSecret: string; - siteName: string; + name: string; email: string; emailPswd: string; } diff --git a/src/interface/platform.interface.ts b/src/interface/platform.interface.ts new file mode 100644 index 0000000..bc5e4d8 --- /dev/null +++ b/src/interface/platform.interface.ts @@ -0,0 +1,287 @@ +// src/interface/platform.interface.ts + +/** + * 电商平台抽象接口 + * 定义所有平台必须实现的通用方法 + */ +export interface IPlatformService { + /** + * 获取产品列表 + * @param site 站点配置信息 + * @returns 产品列表数据 + */ + getProducts(site: any): Promise; + + /** + * 获取单个产品 + * @param site 站点配置信息 + * @param id 产品ID + * @returns 产品数据 + */ + getProduct(site: any, id: number): Promise; + + /** + * 获取产品变体列表 + * @param site 站点配置信息 + * @param productId 产品ID + * @returns 变体列表数据 + */ + getVariations(site: any, productId: number): Promise; + + /** + * 获取产品变体详情 + * @param site 站点配置信息 + * @param productId 产品ID + * @param variationId 变体ID + * @returns 变体详情数据 + */ + getVariation(site: any, productId: number, variationId: number): Promise; + + /** + * 获取订单列表 + * @param siteId 站点ID + * @returns 订单列表数据 + */ + getOrders(siteId: number, params: Record): Promise; + + /** + * 获取订单详情 + * @param siteId 站点ID + * @param orderId 订单ID + * @returns 订单详情数据 + */ + getOrder(siteId: number, orderId: string): Promise; + + /** + * 获取订阅列表(如果平台支持) + * @param siteId 站点ID + * @returns 订阅列表数据 + */ + getSubscriptions?(siteId: number): Promise; + + /** + * 获取客户列表 + * @param site 站点配置信息 + * @returns 客户列表数据 + */ + getCustomers(site: any): Promise; + + /** + * 获取单个客户 + * @param site 站点配置信息 + * @param id 客户ID + * @returns 客户数据 + */ + getCustomer(site: any, id: number): Promise; + + /** + * 创建产品 + * @param site 站点配置信息 + * @param data 产品数据 + * @returns 创建结果 + */ + createProduct(site: any, data: any): Promise; + + /** + * 更新产品 + * @param site 站点配置信息 + * @param productId 产品ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateProduct(site: any, productId: string, data: any): Promise; + + /** + * 更新产品状态 + * @param site 站点配置信息 + * @param productId 产品ID + * @param status 产品状态 + * @param stockStatus 库存状态 + * @returns 更新结果 + */ + updateProductStatus(site: any, productId: string, status: string, stockStatus: string): Promise; + + /** + * 更新产品变体 + * @param site 站点配置信息 + * @param productId 产品ID + * @param variationId 变体ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateVariation(site: any, productId: string, variationId: string, data: any): Promise; + + /** + * 更新订单 + * @param site 站点配置信息 + * @param orderId 订单ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateOrder(site: any, orderId: string, data: Record): Promise; + + /** + * 创建物流信息 + * @param site 站点配置信息 + * @param orderId 订单ID + * @param data 物流数据 + * @returns 创建结果 + */ + createShipment(site: any, orderId: string, data: any): Promise; + + /** + * 删除物流信息 + * @param site 站点配置信息 + * @param orderId 订单ID + * @param trackingId 物流跟踪ID + * @returns 删除结果 + */ + deleteShipment(site: any, orderId: string, trackingId: string): Promise; + + /** + * 批量处理产品 + * @param site 站点配置信息 + * @param data 批量操作数据 + * @returns 处理结果 + */ + batchProcessProducts(site: any, data: { create?: any[]; update?: any[]; delete?: any[] }): Promise; + + /** + * 获取 api 客户端 + * @param site 站点配置信息 + * @returns api 客户端 + */ + getApiClient(site: any): any; + + /** + * 获取客户列表 + * @param site 站点配置信息 + * @returns 客户列表数据 + */ + getCustomers(site: any): Promise; + + /** + * 获取单个客户 + * @param site 站点配置信息 + * @param id 客户ID + * @returns 客户数据 + */ + getCustomer(site: any, id: number): Promise; + + /** + * 获取评论列表 + * @param site 站点配置信息 + * @returns 评论列表数据 + */ + getReviews(site: any): Promise; + + /** + * 创建评论 + * @param site 站点配置信息 + * @param data 评论数据 + * @returns 创建结果 + */ + createReview(site: any, data: any): Promise; + + /** + * 更新评论 + * @param site 站点配置信息 + * @param reviewId 评论ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateReview(site: any, reviewId: number, data: any): Promise; + + /** + * 删除评论 + * @param site 站点配置信息 + * @param reviewId 评论ID + * @returns 删除结果 + */ + deleteReview(site: any, reviewId: number): Promise; + + /** + * 获取分页资源 + * @param site 站点配置信息 + * @param resource 资源类型 + * @param params 查询参数 + * @param namespace API命名空间 + * @returns 分页数据 + */ + fetchResourcePaged(site: any, resource: string, params: Record, namespace?: any): Promise<{ items: T[]; total: number; totalPages: number; page: number; per_page: number }>; + + /** + * 获取分页媒体 + * @param site 站点配置信息 + * @param params 查询参数 + * @returns 分页媒体数据 + */ + fetchMediaPaged(site: any, params: Record): Promise<{ items: any[]; total: number; totalPages: number; page: number; per_page: number }>; + + /** + * 删除媒体 + * @param siteId 站点ID + * @param mediaId 媒体ID + * @param force 是否强制删除 + * @returns 删除结果 + */ + deleteMedia(siteId: number, mediaId: number, force?: boolean): Promise; + + /** + * 更新媒体 + * @param siteId 站点ID + * @param mediaId 媒体ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateMedia(siteId: number, mediaId: number, data: any): Promise; + + /** + * 转换媒体为WebP格式 + * @param siteId 站点ID + * @param mediaIds 媒体ID列表 + * @returns 转换结果 + */ + convertMediaToWebp(siteId: number, mediaIds: Array): Promise<{ converted: any[]; failed: Array<{ id: number | string; error: string }> }>; + + /** + * 获取webhook列表 + * @param site 站点配置信息 + * @param params 查询参数 + * @returns 分页webhook列表 + */ + getWebhooks(site: any, params: any): Promise; + + /** + * 获取单个webhook + * @param site 站点配置信息 + * @param webhookId webhook ID + * @returns webhook详情 + */ + getWebhook(site: any, webhookId: string | number): Promise; + + /** + * 创建webhook + * @param site 站点配置信息 + * @param data webhook数据 + * @returns 创建结果 + */ + createWebhook(site: any, data: any): Promise; + + /** + * 更新webhook + * @param site 站点配置信息 + * @param webhookId webhook ID + * @param data 更新数据 + * @returns 更新结果 + */ + updateWebhook(site: any, webhookId: string | number, data: any): Promise; + + /** + * 删除webhook + * @param site 站点配置信息 + * @param webhookId webhook ID + * @returns 删除结果 + */ + deleteWebhook(site: any, webhookId: string | number): Promise; +} diff --git a/src/interface/site-adapter.interface.ts b/src/interface/site-adapter.interface.ts new file mode 100644 index 0000000..8872e3f --- /dev/null +++ b/src/interface/site-adapter.interface.ts @@ -0,0 +1,170 @@ +import { + CreateReviewDTO, + UpdateReviewDTO, + UnifiedMediaDTO, + UnifiedOrderDTO, + UnifiedPaginationDTO, + UnifiedProductDTO, + UnifiedReviewDTO, + UnifiedSearchParamsDTO, + UnifiedSubscriptionDTO, + UnifiedCustomerDTO, + UnifiedWebhookDTO, + UnifiedWebhookPaginationDTO, + CreateWebhookDTO, + UpdateWebhookDTO, +} from '../dto/site-api.dto'; + +export interface ISiteAdapter { + /** + * 获取产品列表 + */ + getProducts(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 获取单个产品 + */ + getProduct(id: string | number): Promise; + + /** + * 获取订单列表 + */ + getOrders(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 获取单个订单 + */ + getOrder(id: string | number): Promise; + + /** + * 获取订阅列表 + */ + getSubscriptions(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 获取媒体列表 + */ + getMedia(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 创建媒体 + */ + createMedia(file: any): Promise; + + /** + * 获取评论列表 + */ + getReviews(params: UnifiedSearchParamsDTO): Promise>; + + /** + * 创建评论 + */ + createReview(data: CreateReviewDTO): Promise; + + /** + * 更新评论 + */ + updateReview(id: number, data: UpdateReviewDTO): Promise; + + /** + * 删除评论 + */ + deleteReview(id: number): Promise; + + /** + * 创建产品 + */ + createProduct(data: Partial): Promise; + + /** + * 更新产品 + */ + updateProduct(id: string | number, data: Partial): Promise; + + /** + * 更新产品变体 + */ + updateVariation(productId: string | number, variationId: string | number, data: any): Promise; + + /** + * 获取订单备注 + */ + getOrderNotes(orderId: string | number): Promise; + + /** + * 创建订单备注 + */ + createOrderNote(orderId: string | number, data: any): Promise; + + /** + * 删除产品 + */ + deleteProduct(id: string | number): Promise; + + batchProcessProducts?(data: { create?: any[]; update?: any[]; delete?: Array }): Promise; + + createOrder(data: Partial): Promise; + updateOrder(id: string | number, data: Partial): Promise; + deleteOrder(id: string | number): Promise; + + batchProcessOrders?(data: { create?: any[]; update?: any[]; delete?: Array }): Promise; + + getCustomers(params: UnifiedSearchParamsDTO): Promise>; + getCustomer(id: string | number): Promise; + createCustomer(data: Partial): Promise; + updateCustomer(id: string | number, data: Partial): Promise; + deleteCustomer(id: string | number): Promise; + + batchProcessCustomers?(data: { create?: any[]; update?: any[]; delete?: Array }): Promise; + + /** + * 获取webhooks列表 + */ + getWebhooks(params: UnifiedSearchParamsDTO): Promise; + + /** + * 获取单个webhook + */ + getWebhook(id: string | number): Promise; + + /** + * 创建webhook + */ + createWebhook(data: CreateWebhookDTO): Promise; + + /** + * 更新webhook + */ + updateWebhook(id: string | number, data: UpdateWebhookDTO): Promise; + + /** + * 删除webhook + */ + deleteWebhook(id: string | number): Promise; + + /** + * 获取站点链接列表 + */ + getLinks(): Promise>; + + /** + * 订单发货 + */ + shipOrder(orderId: string | number, data: { + tracking_number?: string; + shipping_provider?: string; + shipping_method?: string; + items?: Array<{ + order_item_id: number; + quantity: number; + }>; + }): Promise; + + /** + * 取消订单发货 + */ + cancelShipOrder(orderId: string | number, data: { + reason?: string; + shipment_id?: string; + }): Promise; +} diff --git a/src/job/sync_products.job.ts b/src/job/sync_products.job.ts index fb9b229..d4f4e3e 100644 --- a/src/job/sync_products.job.ts +++ b/src/job/sync_products.job.ts @@ -1,15 +1 @@ -import { FORMAT, ILogger, Logger } from '@midwayjs/core'; -import { IJob, Job } from '@midwayjs/cron'; - -@Job({ - cronTime: FORMAT.CRONTAB.EVERY_DAY, - runOnInit: true, -}) -export class SyncProductJob implements IJob { - @Logger() - logger: ILogger; - - onTick() { - } - onComplete?(result: any) {} -} +export {} diff --git a/src/db/seed/index.ts b/src/main.ts similarity index 100% rename from src/db/seed/index.ts rename to src/main.ts diff --git a/src/middleware/query-normalize.middleware.ts b/src/middleware/query-normalize.middleware.ts new file mode 100644 index 0000000..e334690 --- /dev/null +++ b/src/middleware/query-normalize.middleware.ts @@ -0,0 +1,140 @@ +import { Middleware, IMiddleware } from '@midwayjs/core'; +import { NextFunction, Context } from '@midwayjs/koa'; +import * as qs from 'qs'; + +@Middleware() + export class QueryNormalizeMiddleware implements IMiddleware { + // 数值与布尔转换函数,用于将字符串转换为合适的类型 + private toPrimitive(value: any): any { + const s = String(value); + if (s === 'true') return true; + if (s === 'false') return false; + const n = Number(s); + return Number.isFinite(n) && s !== '' ? n : value; + } + + // 深度遍历对象并对字符串进行trim + private trimDeep(input: any): any { + if (input === null || input === undefined) return input; + if (typeof input === 'string') return input.trim(); + if (Array.isArray(input)) return input.map(v => this.trimDeep(v)); + if (typeof input === 'object') { + const out: Record = {}; + for (const key of Object.keys(input)) { + out[key] = this.trimDeep((input as any)[key]); + } + return out; + } + return input; + } + + // 将路径数组对应的值赋到对象中,支持构建嵌套结构与数组 + private assignByPath(target: Record, path: string[], value: any): void { + let cur: any = target; + for (let i = 0; i < path.length; i++) { + const key = path[i]; + const isLast = i === path.length - 1; + if (isLast) { + if (key === '') { + if (!Array.isArray(cur)) return; + cur.push(value); + } else { + if (cur[key] === undefined) cur[key] = value; + else if (Array.isArray(cur[key])) cur[key].push(value); + else cur[key] = [cur[key], value]; + } + } else { + if (!cur[key] || typeof cur[key] !== 'object') cur[key] = {}; + cur = cur[key]; + } + } + } + + // 解析可能为 JSON 字符串或鍵值串的输入为对象 + private parseLooseObject(input: any): Record { + if (!input) return {}; + if (typeof input === 'object') return input as Record; + const str = String(input).trim(); + try { + if (str.startsWith('{') || str.startsWith('[')) { + const json = JSON.parse(str); + if (json && typeof json === 'object') return json as Record; + } + } catch {} + const obj: Record = {}; + const pairs = str.split(/[&;,]/).map(s => s.trim()).filter(Boolean); + for (const pair of pairs) { + const idxEq = pair.indexOf('='); + const idxColon = pair.indexOf(':'); + const idx = idxEq >= 0 ? idxEq : idxColon; + if (idx < 0) continue; + const key = decodeURIComponent(pair.slice(0, idx)).trim(); + const valueRaw = decodeURIComponent(pair.slice(idx + 1)).trim(); + obj[key] = this.toPrimitive(valueRaw); + } + return obj; + } + + resolve() { + return async (ctx: Context, next: NextFunction) => { + const raw = String((ctx.request as any).querystring || ''); + const parsed = qs.parse(raw, { allowDots: true, depth: 10, ignoreQueryPrefix: false, comma: true }); + const query = { ...(ctx.request.query || {}), ...(parsed as any) } as Record; + const trimmedTop: Record = {}; + for (const k of Object.keys(query)) { + const v = (query as any)[k]; + trimmedTop[k] = typeof v === 'string' ? String(v).trim() : v; + } + Object.assign(query, trimmedTop); + + // 解析 where 对象,支持 JSON 字符串与括号或点号语法 + const hasWhereInput = (query as any).where !== undefined; + let whereObj: Record = this.parseLooseObject((query as any).where); + for (const k of Object.keys(query)) { + if (k === 'where') continue; + if (k.startsWith('where[') || k.startsWith('where.')) { + const pathStr = k.replace(/^where\.?/, '').replace(/\]/g, '').replace(/\[/g, '.'); + const path = pathStr.split('.'); + const val = this.toPrimitive((query as any)[k]); + this.assignByPath(whereObj, path, val); + } + } + const hasWhereBracketKeys = Object.keys(query).some(k => k.startsWith('where[') || k.startsWith('where.')); + if (hasWhereInput || hasWhereBracketKeys) (query as any).where = this.trimDeep(whereObj); + + // 解析 order 对象,支持 JSON 字符串与括号或点号语法 + const hasOrderInput = (query as any).order !== undefined; + let orderObj: Record = this.parseLooseObject((query as any).order); + for (const k of Object.keys(query)) { + if (k === 'order') continue; + if (k.startsWith('order[') || k.startsWith('order.')) { + const pathStr = k.replace(/^order\.?/, '').replace(/\]/g, '').replace(/\[/g, '.'); + const path = pathStr.split('.'); + const val = this.toPrimitive((query as any)[k]); + this.assignByPath(orderObj, path, val); + } + } + const hasOrderBracketKeys = Object.keys(query).some(k => k.startsWith('order[') || k.startsWith('order.')); + if (hasOrderInput || hasOrderBracketKeys) (query as any).order = this.trimDeep(orderObj); + + // 将常见分页参数转换为数字类型 + if (query.page !== undefined) (query as any).page = Number(query.page); + if ((query as any).page_size !== undefined) (query as any).page_size = Number((query as any).page_size); + if ((query as any).per_page !== undefined) (query as any).per_page = Number((query as any).per_page); + if ((query as any).customer_id !== undefined) (query as any).customer_id = Number((query as any).customer_id); + + ctx.request.query = query as any; + (ctx as any).query = query as any; + return await next(); + }; + } + + static getName(): string { + return 'queryNormalize'; + } + + static getPriority(): number { + // 优先级靠前,优先处理查询参数 + return -1; + } +} diff --git a/src/middleware/report.middleware.ts b/src/middleware/report.middleware.ts index 8121353..86e6d54 100644 --- a/src/middleware/report.middleware.ts +++ b/src/middleware/report.middleware.ts @@ -7,7 +7,7 @@ export class ReportMiddleware implements IMiddleware { return async (ctx: Context, next: NextFunction) => { // 控制器前执行的逻辑 const startTime = Date.now(); - // 执行下一个 Web 中间件,最后执行到控制器 + // 执行下一个 Web 中间件,最后执行到控制器 // 这里可以拿到下一个中间件或者控制器的返回值 const result = await next(); // 控制器之后执行的逻辑 diff --git a/src/service/area.service.ts b/src/service/area.service.ts new file mode 100644 index 0000000..e8225d5 --- /dev/null +++ b/src/service/area.service.ts @@ -0,0 +1,80 @@ + +import { Provide } from '@midwayjs/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Like, Repository } from 'typeorm'; +import { Area } from '../entity/area.entity'; +import { CreateAreaDTO, QueryAreaDTO, UpdateAreaDTO } from '../dto/area.dto'; +import * as countries from 'i18n-iso-countries'; + +@Provide() +export class AreaService { + @InjectEntityModel(Area) + areaRepository: Repository; + + constructor() { + // 在服务初始化时注册中文语言包 + countries.registerLocale(require('i18n-iso-countries/langs/zh.json')); + } + + async getAreaList(query: QueryAreaDTO) { + const { currentPage = 1, pageSize = 10, keyword = '' } = query; + const [list, total] = await this.areaRepository.findAndCount({ + where: [{ name: Like(`%${keyword}%`) }, { code: Like(`%${keyword}%`) }], + skip: (currentPage - 1) * pageSize, + take: pageSize, + }); + return { list, total }; + } + + async getAreaById(id: number) { + return this.areaRepository.findOne({ where: { id } }); + } + + async createArea(createAreaDTO: CreateAreaDTO) { + // 根据 code 获取国家中文名称 + const name = countries.getName(createAreaDTO.code, 'zh', { + select: 'official', + }); + + // 如果找不到对应的国家,则抛出错误 + if (!name) { + throw new Error(`无效的国家代码: ${createAreaDTO.code}`); + } + + const area = new Area(); + area.name = name; + area.code = createAreaDTO.code; + return this.areaRepository.save(area); + } + + async updateArea(id: number, updateAreaDTO: UpdateAreaDTO) { + const area = await this.getAreaById(id); + if (!area) { + return null; + } + + // 如果 code 发生变化,则更新 name + if (updateAreaDTO.code && updateAreaDTO.code !== area.code) { + const name = countries.getName(updateAreaDTO.code, 'zh', { + select: 'official', + }); + + if (!name) { + throw new Error(`无效的国家代码: ${updateAreaDTO.code}`); + } + area.name = name; + area.code = updateAreaDTO.code; + } + + return this.areaRepository.save(area); + } + + async deleteArea(id: number) { + const area = await this.getAreaById(id); + if (!area) { + return false; + } + await this.areaRepository.remove(area); + return true; + } +} diff --git a/src/service/canadaPost.service.ts b/src/service/canadaPost.service.ts index 0edee31..e4aa6ec 100644 --- a/src/service/canadaPost.service.ts +++ b/src/service/canadaPost.service.ts @@ -57,7 +57,7 @@ export class CanadaPostService { return builder.buildObject(xmlObj); } - // 默认直接构建(用于 createShipment 这类已有完整结构) + // 默认直接构建(用于 createShipment 这类已有完整结构) return builder.buildObject(data); } diff --git a/src/service/category.service.ts b/src/service/category.service.ts new file mode 100644 index 0000000..ec0b939 --- /dev/null +++ b/src/service/category.service.ts @@ -0,0 +1,105 @@ +import { Provide } from '@midwayjs/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository, Like, In } from 'typeorm'; +import { Category } from '../entity/category.entity'; +import { CategoryAttribute } from '../entity/category_attribute.entity'; +import { Dict } from '../entity/dict.entity'; + +@Provide() +export class CategoryService { + @InjectEntityModel(Category) + categoryModel: Repository; + + @InjectEntityModel(CategoryAttribute) + categoryAttributeModel: Repository; + + @InjectEntityModel(Dict) + dictModel: Repository; + + async getAll() { + return await this.categoryModel.find({ + order: { + sort: 'DESC', + createdAt: 'DESC' + } + }); + } + + async getList(options: { current?: number; pageSize?: number }, name?: string) { + const { current = 1, pageSize = 10 } = options; + const where = name ? [ + { title: Like(`%${name}%`) }, + { name: Like(`%${name}%`) } + ] : []; + + const [list, total] = await this.categoryModel.findAndCount({ + where: where.length ? where : undefined, + skip: (current - 1) * pageSize, + take: pageSize, + order: { + sort: 'DESC', + createdAt: 'DESC' + } + }); + return { list, total }; + } + + async create(data: Partial) { + return await this.categoryModel.save(data); + } + + async update(id: number, data: Partial) { + await this.categoryModel.update(id, data); + return await this.categoryModel.findOneBy({ id }); + } + + async delete(id: number) { + return await this.categoryModel.delete(id); + } + + async findByName(name: string) { + return await this.categoryModel.findOneBy({ name }); + } + + async getCategoryAttributes(categoryId: number) { + const categoryAttributes = await this.categoryAttributeModel.find({ + where: { category: { id: categoryId } }, + relations: ['attributeDict', 'category'], + }); + + return categoryAttributes.map(ca => ca.attributeDict); + } + + async createCategoryAttribute(categoryId: number, attributeDictIds: number[]) { + const category = await this.categoryModel.findOneBy({ id: categoryId }); + if (!category) throw new Error('分类不存在'); + + const dicts = await this.dictModel.findBy({ id: In(attributeDictIds) }); + if (dicts.length !== attributeDictIds.length) throw new Error('部分属性字典不存在'); + + // 检查是否已存在 + const exist = await this.categoryAttributeModel.find({ + where: { + category: { id: categoryId }, + attributeDict: { id: In(attributeDictIds) } + }, + relations: ['attributeDict'] + }); + + const existIds = exist.map(e => e.attributeDict.id); + const newIds = attributeDictIds.filter(id => !existIds.includes(id)); + + const newRecords = newIds.map(id => { + const record = new CategoryAttribute(); + record.category = category; + record.attributeDict = dicts.find(d => d.id === id); + return record; + }); + + return await this.categoryAttributeModel.save(newRecords); + } + + async deleteCategoryAttribute(id: number) { + return await this.categoryAttributeModel.delete(id); + } +} diff --git a/src/service/dict.service.ts b/src/service/dict.service.ts new file mode 100644 index 0000000..38e1944 --- /dev/null +++ b/src/service/dict.service.ts @@ -0,0 +1,205 @@ +import { Provide } from '@midwayjs/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository, Like } from 'typeorm'; +import { Dict } from '../entity/dict.entity'; +import { DictItem } from '../entity/dict_item.entity'; +import { CreateDictDTO, UpdateDictDTO } from '../dto/dict.dto'; +import { CreateDictItemDTO, UpdateDictItemDTO } from '../dto/dict.dto'; +import * as xlsx from 'xlsx'; + +@Provide() +export class DictService { + + @InjectEntityModel(Dict) + dictModel: Repository; + + @InjectEntityModel(DictItem) + dictItemModel: Repository; + + // 格式化名称为 kebab-case + private formatName(name: string): string { + // 只替换空格和下划线 + return String(name).replace(/[_\s]+/g, '-').toLowerCase(); + } + + // 生成并返回字典的XLSX模板 + getDictXLSXTemplate() { + // 定义表头 + const headers = ['name', 'title']; + // 创建一个新的工作表 + const ws = xlsx.utils.aoa_to_sheet([headers]); + // 创建一个新的工作簿 + const wb = xlsx.utils.book_new(); + // 将工作表添加到工作簿 + xlsx.utils.book_append_sheet(wb, ws, 'Dicts'); + // 将工作簿写入缓冲区 + return xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' }); + } + + // 从XLSX文件导入字典 + async importDictsFromXLSX(buffer: Buffer) { + // 读取缓冲区中的工作簿 + const wb = xlsx.read(buffer, { type: 'buffer' }); + // 获取第一个工作表的名称 + const wsname = wb.SheetNames[0]; + // 获取第一个工作表 + const ws = wb.Sheets[wsname]; + // 将工作表转换为JSON对象数组 + const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title'] }).slice(1); + // 创建要保存的字典实体数组 + const dicts = data.map((row: any) => { + const dict = new Dict(); + dict.name = this.formatName(row.name); + dict.title = row.title; + return dict; + }); + // 保存字典实体数组到数据库 + await this.dictModel.save(dicts); + // 返回成功导入的记录数 + return { success: true, count: dicts.length }; + } + + // 生成并返回字典项的XLSX模板 + getDictItemXLSXTemplate() { + const headers = ['name', 'title', 'titleCN', 'value', 'sort', 'image', 'shortName']; + const ws = xlsx.utils.aoa_to_sheet([headers]); + const wb = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(wb, ws, 'DictItems'); + return xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' }); + } + + // 从XLSX文件导入字典项 + async importDictItemsFromXLSX(buffer: Buffer, dictId: number) { + const dict = await this.dictModel.findOneBy({ id: dictId }); + if (!dict) { + throw new Error('指定的字典不存在'); + } + const wb = xlsx.read(buffer, { type: 'buffer' }); + const wsname = wb.SheetNames[0]; + const ws = wb.Sheets[wsname]; + // 支持titleCN字段的导入 + const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title', 'titleCN', 'value', 'sort', 'image', 'shortName'] }).slice(1); + + const items = data.map((row: any) => { + const item = new DictItem(); + item.name = this.formatName(row.name); + item.title = row.title; + item.titleCN = row.titleCN; // 保存中文名称 + item.value = row.value; + item.image = row.image; + item.shortName = row.shortName; + item.sort = row.sort || 0; + item.dict = dict; + return item; + }); + + await this.dictItemModel.save(items); + return { success: true, count: items.length }; + } + getDict(where: { name?: string; id?: number; }, relations: string[]) { + if (!where.name && !where.id) { + throw new Error('必须提供 name 或 id'); + } + return this.dictModel.findOne({ where, relations }); + } + // 获取字典列表,支持按标题搜索 + async getDicts(options: { title?: string; name?: string; }) { + const where = { + title: options.title ? Like(`%${options.title}%`) : undefined, + name: options.name ? Like(`%${options.name}%`) : undefined, + } + return this.dictModel.find({ where }); + } + + // 创建新字典 + async createDict(createDictDTO: CreateDictDTO) { + const dict = new Dict(); + dict.name = this.formatName(createDictDTO.name); + dict.title = createDictDTO.title; + return this.dictModel.save(dict); + } + + // 更新字典 + async updateDict(id: number, updateDictDTO: UpdateDictDTO) { + if (updateDictDTO.name) { + updateDictDTO.name = this.formatName(updateDictDTO.name); + } + await this.dictModel.update(id, updateDictDTO); + return this.dictModel.findOneBy({ id }); + } + + // 删除字典及其所有字典项 + async deleteDict(id: number) { + // 首先删除该字典下的所有字典项 + await this.dictItemModel.delete({ dict: { id } }); + // 然后删除字典本身 + const result = await this.dictModel.delete(id); + return result.affected > 0; + } + + // 获取字典项列表,支持按 dictId 过滤 + async getDictItems(params: { dictId?: number; name?: string; title?: string; }) { + const { dictId, name, title } = params; + const where: any = {}; + + if (dictId) { + where.dict = { id: dictId }; + } + if (name) { + where.name = Like(`%${name}%`); + } + if (title) { + where.title = Like(`%${title}%`); + } + + // 如果提供了 dictId,则只返回该字典下的项 + if (params.dictId) { + return this.dictItemModel.find({ where }); + } + // 否则,返回所有字典项 + return this.dictItemModel.find(); + } + + // 创建新字典项 + async createDictItem(createDictItemDTO: CreateDictItemDTO) { + const dict = await this.dictModel.findOneBy({ id: createDictItemDTO.dictId }); + if (!dict) { + throw new Error(`创建新字典项,指定的字典ID为${createDictItemDTO.dictId},但不存在`); + } + const item = new DictItem(); + item.name = this.formatName(createDictItemDTO.name); + item.title = createDictItemDTO.title; + item.titleCN = createDictItemDTO.titleCN; // 保存中文名称 + item.image = createDictItemDTO.image; + item.shortName = createDictItemDTO.shortName; + item.dict = dict; + return this.dictItemModel.save(item); + } + + // 更新字典项 + async updateDictItem(id: number, updateDictItemDTO: UpdateDictItemDTO) { + if (updateDictItemDTO.name) { + updateDictItemDTO.name = this.formatName(updateDictItemDTO.name); + } + await this.dictItemModel.update(id, updateDictItemDTO); + return this.dictItemModel.findOneBy({ id }); + } + + // 删除字典项 + async deleteDictItem(id: number) { + const result = await this.dictItemModel.delete(id); + return result.affected > 0; + } + + // 根据字典名称获取字典项列表 + async getDictItemsByDictName(dictName: string) { + // 查找字典 + const dict = await this.dictModel.findOne({ where: { name: dictName } }); + // 如果字典不存在,则返回空数组 + if (!dict) { + return []; + } + // 返回该字典下的所有字典项 + return this.dictItemModel.find({ where: { dict: { id: dict.id } } }); + } +} diff --git a/src/service/logistics.service.ts b/src/service/logistics.service.ts index a79503d..067f4e5 100644 --- a/src/service/logistics.service.ts +++ b/src/service/logistics.service.ts @@ -228,7 +228,7 @@ export class LogisticsService { async removeShipment(shipmentId: number) { try { const shipment: Shipment = await this.shipmentModel.findOneBy({ id: shipmentId }); - if (shipment.state !== '190') { // todo,写常数 + if (shipment.state !== '190') { // todo,写常数 throw new Error('订单当前状态无法删除'); } const order: Order = await this.orderModel.findOneBy({ id: shipment.order_id }); @@ -347,7 +347,7 @@ export class LogisticsService { // 添加运单 resShipmentOrder = await this.uniExpressService.createShipment(reqBody); - // 记录物流信息,并将订单状态转到完成 + // 记录物流信息,并将订单状态转到完成 if (resShipmentOrder.status === 'SUCCESS') { order.orderStatus = ErpOrderStatus.COMPLETED; } else { @@ -359,7 +359,7 @@ export class LogisticsService { await dataSource.transaction(async manager => { const orderRepo = manager.getRepository(Order); const shipmentRepo = manager.getRepository(Shipment); - const tracking_provider = 'UniUni'; // todo: id未确定,后写进常数 + const tracking_provider = 'UniUni'; // todo: id未确定,后写进常数 // 同步物流信息到woocommerce const site = await this.siteService.get(Number(order.siteId), true); @@ -414,7 +414,7 @@ export class LogisticsService { if (resShipmentOrder.status === 'SUCCESS') { await this.uniExpressService.deleteShipment(resShipmentOrder.data.tno); } - throw new Error(`上游请求错误:${error}`); + throw new Error(`上游请求错误:${error}`); } } @@ -527,14 +527,14 @@ export class LogisticsService { const stock = await stockRepo.findOne({ where: { stockPointId: orderShipments[0].stockPointId, - productSku: item.sku, + sku: item.sku, }, }); stock.quantity += item.quantity; await stockRepo.save(stock); await stockRecordRepo.save({ stockPointId: orderShipments[0].stockPointId, - productSku: item.sku, + sku: item.sku, operationType: StockRecordOperationType.IN, quantityChange: item.quantity, operatorId: userId, @@ -558,14 +558,14 @@ export class LogisticsService { }, }); - // 从数据库批量获取站点信息,构建映射以避免 N+1 查询 + // 从数据库批量获取站点信息,构建映射以避免 N+1 查询 const siteIds = Array.from(new Set(orders.map(o => o.siteId).filter(Boolean))); const { items: sites } = await this.siteService.list({ current: 1, pageSize: 1000, ids: siteIds.join(',') }, false); - const siteMap = new Map(sites.map((s: any) => [String(s.id), s.siteName])); + const siteMap = new Map(sites.map((s: any) => [s.id, s.name])); return orders.map(order => ({ ...order, - siteName: siteMap.get(order.siteId) || '', + name: siteMap.get(order.siteId) || '', })); } @@ -602,7 +602,7 @@ export class LogisticsService { values.push(stockPointId); } - // todo,增加订单号搜索 + // todo,增加订单号搜索 if (externalOrderId) { whereClause += ' AND o.externalOrderId = ?'; values.push(externalOrderId); diff --git a/src/service/order.service.ts b/src/service/order.service.ts index 1d03d70..76e630a 100644 --- a/src/service/order.service.ts +++ b/src/service/order.service.ts @@ -6,14 +6,14 @@ import { In, Like, Repository } from 'typeorm'; import { InjectEntityModel, TypeORMDataSourceManager } from '@midwayjs/typeorm'; import { plainToClass } from 'class-transformer'; import { OrderItem } from '../entity/order_item.entity'; -import { OrderItemOriginal } from '../entity/order_items_original.entity'; + import { OrderSale } from '../entity/order_sale.entity'; import { WpProduct } from '../entity/wp_product.entity'; import { Product } from '../entity/product.entity'; import { OrderFee } from '../entity/order_fee.entity'; import { OrderRefund } from '../entity/order_refund.entity'; -import { OrderRefundItem } from '../entity/order_retund_item.entity'; -import { OrderCoupon } from '../entity/order_copon.entity'; +import { OrderRefundItem } from '../entity/order_refund_item.entity'; +import { OrderCoupon } from '../entity/order_coupon.entity'; import { OrderShipping } from '../entity/order_shipping.entity'; import { Shipment } from '../entity/shipment.entity'; import { Customer } from '../entity/customer.entity'; @@ -32,7 +32,7 @@ import { SiteService } from './site.service'; import { ShipmentItem } from '../entity/shipment_item.entity'; import { UpdateStockDTO } from '../dto/stock.dto'; import { StockService } from './stock.service'; -import { OrderSaleOriginal } from '../entity/order_item_original.entity'; +import { OrderItemOriginal } from '../entity/order_item_original.entity'; @Provide() export class OrderService { @@ -58,8 +58,6 @@ export class OrderService { @InjectEntityModel(OrderSale) orderSaleModel: Repository; - @InjectEntityModel(OrderSaleOriginal) - orderSaleOriginalModel: Repository; @InjectEntityModel(WpProduct) wpProductModel: Repository; @@ -103,38 +101,46 @@ export class OrderService { @Inject() siteService: SiteService; - async syncOrders(siteId: string) { - //TODO: 临时方案,后续记得调整成前端可控制 - const daysRange = 7; - -// 获取当前时间和7天前时间 - const now = new Date(); - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(now.getDate() - daysRange); - -// 格式化时间为ISO 8601 - const after = sevenDaysAgo.toISOString(); - const before = now.toISOString(); - const orders = await this.wpService.getOrders(siteId,{ - after: after, - before: before, - }); // 调用 WooCommerce API 获取订单 + async syncOrders(siteId: number, params: Record = {}) { + // 调用 WooCommerce API 获取订单 + const orders = await this.wpService.getOrders(siteId, params); + let successCount = 0; + let failureCount = 0; for (const order of orders) { - await this.syncSingleOrder(siteId, order); + try { + await this.syncSingleOrder(siteId, order); + successCount++; + } catch (error) { + console.error(`同步订单 ${order.id} 失败:`, error); + failureCount++; + } } + return { + success: failureCount === 0, + successCount, + failureCount, + message: `同步完成: 成功 ${successCount}, 失败 ${failureCount}`, + }; } - async syncOrderById(siteId: string, orderId: string) { - const order = await this.wpService.getOrder(siteId, orderId); - await this.syncSingleOrder(siteId, order, true); + async syncOrderById(siteId: number, orderId: string) { + try { + // 调用 WooCommerce API 获取订单 + const order = await this.wpService.getOrder(siteId, orderId); + await this.syncSingleOrder(siteId, order, true); + return { success: true, message: '同步成功' }; + } catch (error) { + console.error(`同步订单 ${orderId} 失败:`, error); + return { success: false, message: `同步失败: ${error.message}` }; + } } // 订单状态切换表 orderAutoNextStatusMap = { [OrderStatus.RETURN_APPROVED]: OrderStatus.ON_HOLD, // 退款申请已通过转为 on-hold [OrderStatus.RETURN_CANCELLED]: OrderStatus.REFUNDED // 已取消退款转为 refunded } - // 由于 wordpress 订单状态和 我们的订单状态 不一致,需要做转换 - async autoUpdateOrderStatus(siteId: string, order: any) { + // 由于 wordpress 订单状态和 我们的订单状态 不一致,需要做转换 + async autoUpdateOrderStatus(siteId: number, order: any) { console.log('更新订单状态', order) // 其他状态保持不变 const originStatus = order.status; @@ -144,15 +150,15 @@ export class OrderService { } try { const site = await this.siteService.get(siteId); - // 将订单状态同步到 WooCommerce,然后切换至下一状态 + // 将订单状态同步到 WooCommerce,然后切换至下一状态 await this.wpService.updateOrder(site, String(order.id), { status: order.status }); order.status = this.orderAutoNextStatusMap[originStatus]; } catch (error) { - console.error('更新订单状态失败,原因为:', error) + console.error('更新订单状态失败,原因为:', error) } } - // wordpress 发来, - async syncSingleOrder(siteId: string, order: any, forceUpdate = false) { + // wordpress 发来, + async syncSingleOrder(siteId: number, order: any, forceUpdate = false) { let { line_items, shipping_lines, @@ -162,25 +168,22 @@ export class OrderService { ...orderData } = order; console.log('同步进单个订单', order) - if (order.status === OrderStatus.AUTO_DRAFT) { - return; - } const existingOrder = await this.orderModel.findOne({ where: { externalOrderId: order.id, siteId: siteId }, }); - - // 更新状态 + // 矫正状态 await this.autoUpdateOrderStatus(siteId, order); - if (existingOrder) { - await this.orderModel.update({ id: existingOrder.id }, { orderStatus: this.mapOrderStatus(order.status) }); - } - const externalOrderId = order.id; + // 矫正数据库状态 + await this.orderModel.update({ externalOrderId: order.id, siteId: siteId }, { + orderStatus: order.status, + }) + const externalOrderId = order.id; if ( existingOrder && existingOrder.orderStatus !== ErpOrderStatus.COMPLETED && orderData.status === OrderStatus.COMPLETED ) { - this.updateStock(existingOrder); + this.updateStock(existingOrder); return; } if (existingOrder && !existingOrder.is_editable && !forceUpdate) { @@ -238,7 +241,7 @@ export class OrderService { for (const item of items) { const updateStock = new UpdateStockDTO(); updateStock.stockPointId = stockPointId; - updateStock.productSku = item.sku; + updateStock.sku = item.sku; updateStock.quantityChange = item.quantity; updateStock.operationType = StockRecordOperationType.OUT; updateStock.operatorId = 1; @@ -247,7 +250,7 @@ export class OrderService { } } - async saveOrder(siteId: string, order: Order): Promise { + async saveOrder(siteId: number, order: Order): Promise { order.externalOrderId = String(order.id); order.siteId = siteId; delete order.id; @@ -296,7 +299,7 @@ export class OrderService { 'PENDING_RESHIPMENT', 'PENDING_REFUND', ]; - // 如果当前 ERP 状态不可覆盖,则禁止更新 + // 如果当前 ERP 状态不可覆盖,则禁止更新 return !nonOverridableStatuses.includes(currentErpStatus); } @@ -326,7 +329,7 @@ export class OrderService { } async saveOrderItems(params: { - siteId: string; + siteId: number; orderId: number; externalOrderId: string; orderItems: Record[]; @@ -418,39 +421,51 @@ export class OrderService { await this.orderSaleModel.delete(currentOrderSale.map(v => v.id)); } if (!orderItem.sku) return; - let constitution; - if (orderItem.externalVariationId === '0') { - const product = await this.wpProductModel.findOne({ - where: { sku: orderItem.sku }, - }); - if (!product) return; - constitution = product?.constitution; - } else { - const variation = await this.variationModel.findOne({ - where: { sku: orderItem.sku }, - }); - if (!variation) return; - constitution = variation?.constitution; - } - if (!Array.isArray(constitution)) return; + const product = await this.productModel.findOne({ + where: { sku: orderItem.sku }, + relations: ['components'], + }); + + if (!product) return; + const orderSales: OrderSale[] = []; - for (const item of constitution) { - const baseProduct = await this.productModel.findOne({ - where: { sku: item.sku }, - }); + + if (product.components && product.components.length > 0) { + for (const comp of product.components) { + const baseProduct = await this.productModel.findOne({ + where: { sku: comp.sku }, + }); + if (baseProduct) { + const orderSaleItem: OrderSale = plainToClass(OrderSale, { + orderId: orderItem.orderId, + siteId: orderItem.siteId, + externalOrderItemId: orderItem.externalOrderItemId, + productId: baseProduct.id, + name: baseProduct.name, + quantity: comp.quantity * orderItem.quantity, + sku: comp.sku, + isPackage: orderItem.name.toLowerCase().includes('package'), + }); + orderSales.push(orderSaleItem); + } + } + } else { const orderSaleItem: OrderSale = plainToClass(OrderSale, { orderId: orderItem.orderId, siteId: orderItem.siteId, externalOrderItemId: orderItem.externalOrderItemId, - productId: baseProduct.id, - name: baseProduct.name, - quantity: item.quantity * orderItem.quantity, - sku: item.sku, + productId: product.id, + name: product.name, + quantity: orderItem.quantity, + sku: product.sku, isPackage: orderItem.name.toLowerCase().includes('package'), }); orderSales.push(orderSaleItem); } - await this.orderSaleModel.save(orderSales); + + if (orderSales.length > 0) { + await this.orderSaleModel.save(orderSales); + } } async saveOrderRefunds({ @@ -459,14 +474,14 @@ export class OrderService { externalOrderId, refunds, }: { - siteId: string; + siteId: number; orderId: number; externalOrderId: string; refunds: Record[]; }) { for (const item of refunds) { const refund = await this.wpService.getOrderRefund( - siteId, + String(siteId), externalOrderId, item.id ); @@ -753,7 +768,7 @@ export class OrderService { totalQuery += ` AND o.date_created <= ?`; parameters.push(endDate); } - // 支付方式筛选(使用参数化,避免SQL注入) + // 支付方式筛选(使用参数化,避免SQL注入) if (payment_method) { sqlQuery += ` AND o.payment_method LIKE ?`; totalQuery += ` AND o.payment_method LIKE ?`; @@ -785,7 +800,7 @@ export class OrderService { } } - // 仅订阅订单过滤:父订阅订单 或 行项目包含订阅相关元数据(兼容 JSON 与字符串存储) + // 仅订阅订单过滤:父订阅订单 或 行项目包含订阅相关元数据(兼容 JSON 与字符串存储) if (isSubscriptionOnly) { const subCond = ` AND ( @@ -883,7 +898,7 @@ export class OrderService { customer_email: `%${customer_email}%`, }); - // 🔥 关键字搜索:检查 order_item.name 是否包含 keyword + // 🔥 关键字搜索:检查 order_item.name 是否包含 keyword if (keyword) { query.andWhere( `EXISTS ( @@ -1046,7 +1061,7 @@ export class OrderService { } // ------------------------- - // 4. 总量统计(时间段 + siteId) + // 4. 总量统计(时间段 + siteId) // ------------------------- const totalParams: any[] = [startDate, endDate]; const yooneParams: any[] = [startDate, endDate]; @@ -1323,30 +1338,9 @@ export class OrderService { } } - // update order_sale_origin if not exist - try { - const order_sale_origin_count = await this.orderSaleOriginalModel.countBy({ orderId: id }); - if (order_sale_origin_count === 0) { - sales.forEach(async sale => { - const { id: saleId, ...saleData } = sale; - await this.orderSaleOriginalModel.save(saleData); - }); - } - // update order_item_origin if not exist - const order_item_origin_count = await this.orderItemOriginalModel.countBy({ orderId: id }); - if (order_item_origin_count === 0 && items.length != 0) { - items.forEach(async sale => { - const { id: saleId, ...saleData } = sale; - await this.orderItemOriginalModel.save(saleData); - }); - } - } catch (error) { - console.log('create order sale origin error: ', error.message); - } - - // 关联数据:订阅与相关订单(用于前端关联展示) + // 关联数据:订阅与相关订单(用于前端关联展示) let relatedList: any[] = []; try { const related = await this.getRelatedByOrder(id); @@ -1371,8 +1365,8 @@ export class OrderService { return { ...order, - siteName: site?.siteName, - // Site 实体无邮箱字段,这里返回空字符串保持兼容 + name: site?.name, + // Site 实体无邮箱字段,这里返回空字符串保持兼容 email: '', items, sales, @@ -1436,14 +1430,14 @@ export class OrderService { ]), }, }); - // 批量获取订单涉及的站点名称,避免使用配置文件 + // 批量获取订单涉及的站点名称,避免使用配置文件 const siteIds = Array.from(new Set(orders.map(o => o.siteId).filter(Boolean))); const { items: sites } = await this.siteService.list({ current: 1, pageSize: 1000, ids: siteIds.join(',') }, false); - const siteMap = new Map(sites.map((s: any) => [String(s.id), s.siteName])); + const siteMap = new Map(sites.map((s: any) => [String(s.id), s.name])); return orders.map(order => ({ externalOrderId: order.externalOrderId, id: order.id, - siteName: siteMap.get(order.siteId) || '', + name: siteMap.get(String(order.siteId)) || '', })); } @@ -1451,7 +1445,7 @@ export class OrderService { const order = await this.orderModel.findOne({ where: { id } }); if (!order) throw new Error(`订单 ${id}不存在`); const s: any = await this.siteService.get(Number(order.siteId), true); - const site = { id: String(s.id), wpApiUrl: s.apiUrl, consumerKey: s.consumerKey, consumerSecret: s.consumerSecret, siteName: s.siteName, email: '', emailPswd: '' } as WpSite; + const site = { id: String(s.id), wpApiUrl: s.apiUrl, consumerKey: s.consumerKey, consumerSecret: s.consumerSecret, name: s.name, email: '', emailPswd: '' } as WpSite; if (order.status !== OrderStatus.CANCEL) { await this.wpService.updateOrder(site, order.externalOrderId, { status: OrderStatus.CANCEL, @@ -1499,16 +1493,23 @@ export class OrderService { async createOrder(data: Record) { - const { sales, total, billing, customer_email, billing_phone } = data; + // 从数据中解构出需要用的属性 + const { siteId, sales, total, billing, customer_email, billing_phone } = data; + // 如果没有 siteId,则抛出错误 + if (!siteId) { + throw new Error('siteId is required'); + } + // 获取默认数据源 const dataSource = this.dataSourceManager.getDataSource('default'); const now = new Date(); + // 在事务中处理订单创建 return dataSource.transaction(async manager => { const orderRepo = manager.getRepository(Order); const orderSaleRepo = manager.getRepository(OrderSale); - const OrderSaleOriginalRepo = manager.getRepository(OrderSaleOriginal); const productRepo = manager.getRepository(Product); + // 保存订单信息 const order = await orderRepo.save({ - siteId: '-1', + siteId, externalOrderId: '-1', status: OrderStatus.PROCESSING, orderStatus: ErpOrderStatus.PROCESSING, @@ -1522,19 +1523,19 @@ export class OrderService { billing, shipping: billing, }); + // 遍历销售项目并保存 for (const sale of sales) { const product = await productRepo.findOne({ where: { sku: sale.sku } }); const saleItem = { - orderId: order.id, - siteId: '-1', - externalOrderItemId: '-1', - productId: product.id, - name: product.name, - sku: sale.sku, - quantity: sale.quantity, - }; + orderId: order.id, + siteId: order.siteId, + externalOrderItemId: '-1', + productId: product.id, + name: product.name, + sku: sale.sku, + quantity: sale.quantity, + }; await orderSaleRepo.save(saleItem); - await OrderSaleOriginalRepo.save(saleItem); } }); } @@ -1608,11 +1609,11 @@ export class OrderService { }); if (transactionError !== undefined) { - throw new Error(`更新物流信息错误:${transactionError.message}`); + throw new Error(`更新物流信息错误:${transactionError.message}`); } return true; } catch (error) { - throw new Error(`更新发货产品失败:${error.message}`); + throw new Error(`更新发货产品失败:${error.message}`); } } @@ -1627,7 +1628,7 @@ export class OrderService { const orderRepo = manager.getRepository(Order); const orderSaleRepo = manager.getRepository(OrderSale); const orderItemRepo = manager.getRepository(OrderItem); - const OrderItemOriginalRepo = manager.getRepository(OrderItemOriginal); + const productRepo = manager.getRepository(Product); const WpProductRepo = manager.getRepository(WpProduct); @@ -1635,7 +1636,7 @@ export class OrderService { const order = await orderRepo.findOneBy({ id: orderId }); let product: Product; let wpProduct: WpProduct; - let wpOrderItemOriginal: OrderItemOriginal; + await orderSaleRepo.delete({ orderId }); await orderItemRepo.delete({ orderId }); for (const sale of data['sales']) { @@ -1652,13 +1653,8 @@ export class OrderService { for (const item of data['items']) { wpProduct = await WpProductRepo.findOneBy({ sku: item['sku'] }); - wpOrderItemOriginal = await OrderItemOriginalRepo.findOneBy({ sku: item['sku'] }); - let externalVariationId = wpOrderItemOriginal?.externalVariationId; - let price = wpOrderItemOriginal?.price; - if (wpOrderItemOriginal == null) { - externalVariationId = '0'; - price = 0; - } + + await orderItemRepo.save({ orderId, siteId: order.siteId, @@ -1666,8 +1662,7 @@ export class OrderService { name: wpProduct.name, externalOrderId: order.externalOrderId, externalProductId: wpProduct.externalProductId, - externalVariationId: externalVariationId, - price: price, + sku: item['sku'], quantity: item['quantity'], }); @@ -1699,11 +1694,11 @@ export class OrderService { }); if (transactionError !== undefined) { - throw new Error(`更新物流信息错误:${transactionError.message}`); + throw new Error(`更新物流信息错误:${transactionError.message}`); } return true; } catch (error) { - throw new Error(`更新发货产品失败:${error.message}`); + throw new Error(`更新发货产品失败:${error.message}`); } } diff --git a/src/service/product.service.ts b/src/service/product.service.ts index 22973d5..ccc12f6 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -1,44 +1,57 @@ -import { Provide } from '@midwayjs/core'; +import { Inject, Provide } from '@midwayjs/core'; +import * as fs from 'fs'; import { In, Like, Not, Repository } from 'typeorm'; import { Product } from '../entity/product.entity'; -import { Category } from '../entity/category.entity'; import { paginate } from '../utils/paginate.util'; import { PaginationParams } from '../interface'; +import { parse } from 'csv-parse'; + import { - CreateCategoryDTO, - CreateFlavorsDTO, CreateProductDTO, - CreateStrengthDTO, - UpdateCategoryDTO, - UpdateFlavorsDTO, UpdateProductDTO, - UpdateStrengthDTO, + BatchUpdateProductDTO, } from '../dto/product.dto'; import { - CategoryPaginatedResponse, + BrandPaginatedResponse, FlavorsPaginatedResponse, ProductPaginatedResponse, StrengthPaginatedResponse, + SizePaginatedResponse, } from '../dto/reponse.dto'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { WpProduct } from '../entity/wp_product.entity'; import { Variation } from '../entity/variation.entity'; -import { Strength } from '../entity/strength.entity'; -import { Flavors } from '../entity/flavors.entity'; +import { Dict } from '../entity/dict.entity'; +import { DictItem } from '../entity/dict_item.entity'; +import { Context } from '@midwayjs/koa'; +import { TemplateService } from './template.service'; +import { StockService } from './stock.service'; +import { Stock } from '../entity/stock.entity'; +import { StockPoint } from '../entity/stock_point.entity'; +import { ProductStockComponent } from '../entity/product_stock_component.entity'; +import { ProductSiteSku } from '../entity/product_site_sku.entity'; +import { Category } from '../entity/category.entity'; +import { CategoryAttribute } from '../entity/category_attribute.entity'; @Provide() export class ProductService { + @Inject() + ctx: Context; + + @Inject() + templateService: TemplateService; + + @Inject() + stockService: StockService; + @InjectEntityModel(Product) productModel: Repository; - @InjectEntityModel(Category) - categoryModel: Repository; + @InjectEntityModel(Dict) + dictModel: Repository; - @InjectEntityModel(Strength) - strengthModel: Repository; - - @InjectEntityModel(Flavors) - flavorsModel: Repository; + @InjectEntityModel(DictItem) + dictItemModel: Repository; @InjectEntityModel(WpProduct) wpProductModel: Repository; @@ -46,6 +59,118 @@ export class ProductService { @InjectEntityModel(Variation) variationModel: Repository; + @InjectEntityModel(Stock) + stockModel: Repository; + + @InjectEntityModel(StockPoint) + stockPointModel: Repository; + + @InjectEntityModel(ProductStockComponent) + productStockComponentModel: Repository; + + @InjectEntityModel(ProductSiteSku) + productSiteSkuModel: Repository; + + @InjectEntityModel(Category) + categoryModel: Repository; + + + // 获取所有 WordPress 商品 + async getWpProducts() { + return this.wpProductModel.find(); + } + // 获取所有分类 + async getCategoriesAll(): Promise { + return this.categoryModel.find({ + order: { + sort: 'ASC', + }, + }); + } + + // 获取分类下的属性配置 + async getCategoryAttributes(categoryId: number): Promise { + const category = await this.categoryModel.findOne({ + where: { id: categoryId }, + relations: ['attributes', 'attributes.attributeDict', 'attributes.attributeDict.items'], + }); + + if (!category) { + return []; + } + + // 格式化返回,匹配前端期望的数据结构 + return category.attributes.map(attr => ({ + id: attr.id, + dictId: attr.attributeDict.id, + name: attr.attributeDict.name, + title: attr.attributeDict.title, + items: attr.attributeDict.items, // 如果需要返回具体的选项 + })); + } + + // 创建分类 + async createCategory(payload: Partial): Promise { + const exists = await this.categoryModel.findOne({ where: { name: payload.name } }); + if (exists) { + throw new Error('分类已存在'); + } + return this.categoryModel.save(payload); + } + + // 更新分类 + async updateCategory(id: number, payload: Partial): Promise { + const category = await this.categoryModel.findOne({ where: { id } }); + if (!category) { + throw new Error('分类不存在'); + } + await this.categoryModel.update(id, payload); + return this.categoryModel.findOne({ where: { id } }); + } + + // 删除分类 + async deleteCategory(id: number): Promise { + const result = await this.categoryModel.delete(id); + return result.affected > 0; + } + + // 创建分类属性关联 + async createCategoryAttribute(payload: { categoryId: number; dictId: number }): Promise { + const category = await this.categoryModel.findOne({ where: { id: payload.categoryId } }); + if (!category) { + throw new Error('分类不存在'); + } + + const dict = await this.dictModel.findOne({ where: { id: payload.dictId } }); + if (!dict) { + throw new Error('字典不存在'); + } + + const existing = await this.categoryModel.manager.findOne(CategoryAttribute, { + where: { + category: { id: payload.categoryId }, + attributeDict: { id: payload.dictId }, + }, + }); + + if (existing) { + throw new Error('该属性已关联到此分类'); + } + + const attr = this.categoryModel.manager.create(CategoryAttribute, { + category, + attributeDict: dict + }); + return this.categoryModel.manager.save(attr); + } + + // 删除分类属性关联 + async deleteCategoryAttribute(id: number): Promise { + const result = await this.categoryModel.manager.delete(CategoryAttribute, id); + return result.affected > 0; + } + + // async findProductsByName(name: string): Promise { // const where: any = {}; // const nameFilter = name ? name.split(' ').filter(Boolean) : []; @@ -57,7 +182,7 @@ export class ProductService { // where.nameCn = Like(`%${name}%`) // } // where.sku = Not(IsNull()); - // // 查询 SKU 不为空且 name 包含关键字的产品,最多返回 50 条 + // // 查询 SKU 不为空且 name 包含关键字的产品,最多返回 50 条 // return this.productModel.find({ // where, // take: 50, @@ -66,7 +191,9 @@ export class ProductService { async findProductsByName(name: string): Promise { const nameFilter = name ? name.split(' ').filter(Boolean) : []; - const query = this.productModel.createQueryBuilder('product'); + const query = this.productModel.createQueryBuilder('product') + .leftJoinAndSelect('product.category', 'category') + .leftJoinAndSelect('product.siteSkus', 'siteSku'); // 保证 sku 不为空 query.where('product.sku IS NOT NULL'); @@ -75,7 +202,7 @@ export class ProductService { const params: Record = {}; const conditions: string[] = []; - // 英文名关键词全部匹配(AND) + // 英文名关键词全部匹配(AND) if (nameFilter.length > 0) { const nameConds = nameFilter.map((word, index) => { const key = `name${index}`; @@ -105,45 +232,61 @@ export class ProductService { where: { sku, }, + relations: ['category', 'attributes', 'attributes.dict', 'siteSkus'] }); } async getProductList( pagination: PaginationParams, name?: string, - categoryId?: number + brandId?: number, + sortField?: string, + sortOrder?: string ): Promise { - const nameFilter = name ? name.split(' ').filter(Boolean) : []; - const qb = this.productModel .createQueryBuilder('product') - .leftJoin(Category, 'category', 'category.id = product.categoryId') - .leftJoin(Strength, 'strength', 'strength.id = product.strengthId') - .leftJoin(Flavors, 'flavors', 'flavors.id = product.flavorsId') - .select([ - 'product.id as id', - 'product.name as name', - 'product.nameCn as nameCn', - 'product.description as description', - 'product.humidity as humidity', - 'product.sku as sku', - 'product.createdAt as createdAt', - 'product.updatedAt as updatedAt', - 'category.name AS categoryName', - 'strength.name AS strengthName', - 'flavors.name AS flavorsName', - ]); + .leftJoinAndSelect('product.attributes', 'attribute') + .leftJoinAndSelect('attribute.dict', 'dict') + .leftJoinAndSelect('product.category', 'category') + .leftJoinAndSelect('product.siteSkus', 'siteSku'); - // 模糊搜索 name,支持多个关键词 - nameFilter.forEach((word, index) => { - qb.andWhere(`product.name LIKE :name${index}`, { - [`name${index}`]: `%${word}%`, + // 模糊搜索 name,支持多个关键词 + const nameFilter = name ? name.split(' ').filter(Boolean) : []; + if (nameFilter.length > 0) { + const nameConditions = nameFilter + .map((word, index) => `product.name LIKE :name${index}`) + .join(' AND '); + const nameParams = nameFilter.reduce( + (params, word, index) => ({ ...params, [`name${index}`]: `%${word}%` }), + {} + ); + qb.where(`(${nameConditions})`, nameParams); + } + + // 品牌过滤 + if (brandId) { + qb.andWhere(qb => { + const subQuery = qb + .subQuery() + .select('product_attributes_dict_item.productId') + .from('product_attributes_dict_item', 'product_attributes_dict_item') + .where('product_attributes_dict_item.dictItemId = :brandId', { + brandId, + }) + .getQuery(); + return 'product.id IN ' + subQuery; }); - }); + } - // 分类过滤 - if (categoryId) { - qb.andWhere('product.categoryId = :categoryId', { categoryId }); + // 排序 + if (sortField && sortOrder) { + const order = sortOrder === 'ascend' ? 'ASC' : 'DESC'; + const allowedSortFields = ['price', 'promotionPrice', 'createdAt', 'updatedAt', 'sku', 'name']; + if (allowedSortFields.includes(sortField)) { + qb.orderBy(`product.${sortField}`, order); + } + } else { + qb.orderBy('product.createdAt', 'DESC'); } // 分页 @@ -151,9 +294,24 @@ export class ProductService { pagination.pageSize ); - // 执行查询 - const items = await qb.getRawMany(); - const total = await qb.getCount(); + const [items, total] = await qb.getManyAndCount(); + + // 根据类型填充组成信息 + for (const product of items) { + if (product.type === 'single') { + // 单品不持久化组成,这里仅返回一个基于 SKU 的虚拟组成 + const component = new ProductStockComponent(); + component.productId = product.id; + component.sku = product.sku; + component.quantity = 1; + product.components = [component]; + } else { + // 混装商品返回持久化的 SKU 组成 + product.components = await this.productStockComponentModel.find({ + where: { productId: product.id }, + }); + } + } return { items, @@ -162,54 +320,508 @@ export class ProductService { }; } - async createProduct(createProductDTO: CreateProductDTO): Promise { - const { name, description, categoryId, strengthId, flavorsId, humidity } = - createProductDTO; - const isExit = await this.productModel.findOne({ - where: { - categoryId, - strengthId, - flavorsId, - humidity, - }, + async getOrCreateAttribute( + dictName: string, + itemTitle: string, + itemName?: string + ): Promise { + // 查找字典 + const dict = await this.dictModel.findOne({ where: { name: dictName } }); + if (!dict) { + throw new Error(`字典 '${dictName}' 不存在`); + } + + const nameForLookup = itemName || itemTitle; + + // 查找字典项 + let item = await this.dictItemModel.findOne({ + where: { name: nameForLookup, dict: { id: dict.id } }, }); - if (isExit) throw new Error('产品已存在'); + + // 如果字典项不存在,则创建 + if (!item) { + item = new DictItem(); + item.title = itemTitle; + item.name = nameForLookup; + item.dict = dict; + await this.dictItemModel.save(item); + } + + return item; + } + + + async createProduct(createProductDTO: CreateProductDTO): Promise { + const { attributes, sku, categoryId } = createProductDTO; + + // 条件判断(校验属性输入) + if (!Array.isArray(attributes) || attributes.length === 0) { + // 如果提供了 categoryId 但没有 attributes,初始化为空数组 + if (!attributes && categoryId) { + // 继续执行,下面会处理 categoryId + } else { + throw new Error('属性列表不能为空'); + } + } + + const safeAttributes = attributes || []; + + // 解析属性输入(按 id 或 dictName 创建/关联字典项) + const resolvedAttributes: DictItem[] = []; + let categoryItem: Category | null = null; + + // 如果提供了 categoryId,设置分类 + if (categoryId) { + categoryItem = await this.categoryModel.findOne({ + where: { id: categoryId }, + relations: ['attributes', 'attributes.attributeDict'] + }); + if (!categoryItem) throw new Error(`分类 ID ${categoryId} 不存在`); + } + + for (const attr of safeAttributes) { + // 如果属性是分类,特殊处理 + if (attr.dictName === 'category') { + if (attr.id) { + categoryItem = await this.categoryModel.findOne({ + where: { id: attr.id }, + relations: ['attributes', 'attributes.attributeDict'] + }); + } else if (attr.name) { + categoryItem = await this.categoryModel.findOne({ + where: { name: attr.name }, + relations: ['attributes', 'attributes.attributeDict'] + }); + } else if (attr.title) { + // 尝试用 title 匹配 name 或 title + categoryItem = await this.categoryModel.findOne({ + where: [ + { name: attr.title }, + { title: attr.title } + ], + relations: ['attributes', 'attributes.attributeDict'] + }); + } + continue; + } + + let item: DictItem | null = null; + if (attr.id) { + // 如果传入了 id,直接查找字典项并使用,不强制要求 dictName + item = await this.dictItemModel.findOne({ where: { id: attr.id }, relations: ['dict'] }); + if (!item) throw new Error(`字典项 ID ${attr.id} 不存在`); + } else { + // 当未提供 id 时,需要 dictName 与 title/name 信息创建或获取字典项 + if (!attr?.dictName) throw new Error('属性项缺少字典名称'); + const titleOrName = attr.title || attr.name; + if (!titleOrName) throw new Error('新建字典项需要提供 title 或 name'); + item = await this.getOrCreateAttribute(attr.dictName, titleOrName, attr.name); + } + resolvedAttributes.push(item); + } + + // 检查完全相同属性组合是否已存在(避免重复) + const qb = this.productModel.createQueryBuilder('product'); + resolvedAttributes.forEach((attr, index) => { + qb.innerJoin( + 'product.attributes', + `attr${index}`, + `attr${index}.id = :attrId${index}`, + { [`attrId${index}`]: attr.id } + ); + }); + const isExist = await qb.getOne(); + if (isExist) throw new Error('相同产品属性的产品已存在'); + + // 创建新产品实例(绑定属性与基础字段) const product = new Product(); - product.name = name; - product.description = description; - product.categoryId = categoryId; - product.strengthId = strengthId; - product.flavorsId = flavorsId; - product.humidity = humidity; - const categoryKey = ( - await this.categoryModel.findOne({ where: { id: categoryId } }) - ).unique_key; - const strengthKey = ( - await this.strengthModel.findOne({ where: { id: strengthId } }) - ).unique_key; - const flavorsKey = ( - await this.flavorsModel.findOne({ where: { id: flavorsId } }) - ).unique_key; - product.sku = `${categoryKey}-${flavorsKey}-${strengthKey}-${humidity}`; - return await this.productModel.save(product); + + // 使用 merge 填充基础字段,排除特殊处理字段 + const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, siteSkus: _siteSkus, ...simpleFields } = createProductDTO; + this.productModel.merge(product, simpleFields); + + product.attributes = resolvedAttributes; + if (categoryItem) { + product.category = categoryItem; + } + // 确保默认类型 + if (!product.type) product.type = 'single'; + + // 生成或设置 SKU(基于属性字典项的 name 生成) + if (sku) { + product.sku = sku; + } else { + product.sku = await this.templateService.render('product.sku', product); + } + + const savedProduct = await this.productModel.save(product); + + // 保存站点 SKU 列表 + if (createProductDTO.siteSkus && createProductDTO.siteSkus.length > 0) { + const siteSkus = createProductDTO.siteSkus.map(code => { + const s = new ProductSiteSku(); + s.siteSku = code; + s.product = savedProduct; + return s; + }); + await this.productSiteSkuModel.save(siteSkus); + } + + // 保存组件信息 + if (createProductDTO.components && createProductDTO.components.length > 0) { + await this.setProductComponents(savedProduct.id, createProductDTO.components); + // 重新加载带组件的产品 + return await this.productModel.findOne({ where: { id: savedProduct.id }, relations: ['attributes', 'attributes.dict', 'category', 'components', 'siteSkus'] }); + } + + return savedProduct; } async updateProduct( id: number, updateProductDTO: UpdateProductDTO ): Promise { - // 确认产品是否存在 - const product = await this.productModel.findOneBy({ id }); + // 检查产品是否存在(包含属性关系) + const product = await this.productModel.findOne({ where: { id }, relations: ['attributes', 'attributes.dict', 'category'] }); if (!product) { throw new Error(`产品 ID ${id} 不存在`); } - // 更新产品 - await this.productModel.update(id, updateProductDTO); - // 返回更新后的产品 - return await this.productModel.findOneBy({ id }); + + // 使用 merge 更新基础字段,排除特殊处理字段 + const { attributes: _attrs, categoryId: _cid, sku: _sku, components: _components, siteSkus: _siteSkus, ...simpleFields } = updateProductDTO; + this.productModel.merge(product, simpleFields); + + // 处理分类更新 + if (updateProductDTO.categoryId !== undefined) { + if (updateProductDTO.categoryId) { + const categoryItem = await this.categoryModel.findOne({ where: { id: updateProductDTO.categoryId } }); + if (!categoryItem) throw new Error(`分类 ID ${updateProductDTO.categoryId} 不存在`); + product.category = categoryItem; + } else { + // 如果传了 0 或 null,可以清除分类(根据需求) + // product.category = null; + } + } + + // 处理站点 SKU 更新 + if (updateProductDTO.siteSkus !== undefined) { + // 删除旧的 siteSkus + await this.productSiteSkuModel.delete({ productId: id }); + + // 如果有新的 siteSkus,则保存 + if (updateProductDTO.siteSkus.length > 0) { + const siteSkus = updateProductDTO.siteSkus.map(code => { + const s = new ProductSiteSku(); + s.siteSku = code; + s.productId = id; + return s; + }); + await this.productSiteSkuModel.save(siteSkus); + } + } + + // 处理 SKU 更新 + if (updateProductDTO.sku !== undefined) { + // 校验 SKU 唯一性(如变更) + const newSku = updateProductDTO.sku; + if (newSku && newSku !== product.sku) { + const exist = await this.productModel.findOne({ where: { sku: newSku } }); + if (exist) { + throw new Error('SKU 已存在,请更换后重试'); + } + product.sku = newSku; + } + } + + // 处理属性更新(若传入 attributes 则按字典名称替换对应项) + if (Array.isArray(updateProductDTO.attributes) && updateProductDTO.attributes.length > 0) { + const nextAttributes: DictItem[] = [...(product.attributes || [])]; + + const replaceAttr = (dictName: string, item: DictItem) => { + const idx = nextAttributes.findIndex(a => a.dict?.name === dictName); + if (idx >= 0) nextAttributes[idx] = item; else nextAttributes.push(item); + }; + + for (const attr of updateProductDTO.attributes) { + // 如果属性是分类,特殊处理 + if (attr.dictName === 'category') { + if (attr.id) { + const categoryItem = await this.categoryModel.findOneBy({ id: attr.id }); + if (categoryItem) product.category = categoryItem; + } + continue; + } + + let item: DictItem | null = null; + if (attr.id) { + // 当提供 id 时直接查询字典项,不强制要求 dictName + item = await this.dictItemModel.findOne({ where: { id: attr.id }, relations: ['dict'] }); + if (!item) throw new Error(`字典项 ID ${attr.id} 不存在`); + } else { + // 未提供 id 则需要 dictName 与 title/name 信息 + if (!attr?.dictName) throw new Error('属性项缺少字典名称'); + const titleOrName = attr.title || attr.name; + if (!titleOrName) throw new Error('新建字典项需要提供 title 或 name'); + item = await this.getOrCreateAttribute(attr.dictName, titleOrName, attr.name); + } + // 以传入的 dictName 或查询到的 item.dict.name 作为替换键 + const dictKey = attr.dictName || item?.dict?.name; + if (!dictKey) throw new Error('无法确定字典名称用于替换属性'); + replaceAttr(dictKey, item); + } + + product.attributes = nextAttributes; + } + + // 条件判断(更新商品类型,如传入) + if (updateProductDTO.type !== undefined) { + product.type = updateProductDTO.type as any; + } + + // 保存更新后的产品 + const saved = await this.productModel.save(product); + + // 处理组件更新 + if (updateProductDTO.components !== undefined) { + // 如果 components 为空数组,则删除所有组件? setProductComponents 会处理 + await this.setProductComponents(saved.id, updateProductDTO.components); + } + + return saved; } - - async updateProductNameCn(id: number, nameCn: string): Promise { + + async batchUpdateProduct( + batchUpdateProductDTO: BatchUpdateProductDTO + ): Promise { + const { ids, ...updateData } = batchUpdateProductDTO; + if (!ids || ids.length === 0) { + throw new Error('未选择任何产品'); + } + + // 检查 updateData 中是否有复杂字段 (attributes, categoryId, type, sku) + // 如果包含复杂字段,需要复用 updateProduct 的逻辑 + const hasComplexFields = + updateData.attributes !== undefined || + updateData.categoryId !== undefined || + updateData.type !== undefined || + updateData.sku !== undefined; + + if (hasComplexFields) { + // 循环调用 updateProduct + for (const id of ids) { + const updateDTO = new UpdateProductDTO(); + // 复制属性 + Object.assign(updateDTO, updateData); + await this.updateProduct(id, updateDTO); + } + } else { + // 简单字段,直接批量更新以提高性能 + // UpdateProductDTO 里的简单字段: name, nameCn, description, price, promotionPrice + + const simpleUpdate: any = {}; + if (updateData.name !== undefined) simpleUpdate.name = updateData.name; + if (updateData.nameCn !== undefined) simpleUpdate.nameCn = updateData.nameCn; + if (updateData.description !== undefined) simpleUpdate.description = updateData.description; + if (updateData.shortDescription !== undefined) simpleUpdate.shortDescription = updateData.shortDescription; + if (updateData.price !== undefined) simpleUpdate.price = updateData.price; + if (updateData.promotionPrice !== undefined) simpleUpdate.promotionPrice = updateData.promotionPrice; + + if (Object.keys(simpleUpdate).length > 0) { + await this.productModel.update({ id: In(ids) }, simpleUpdate); + } + } + + return true; + } + + async batchDeleteProduct(ids: number[]): Promise<{ success: number; failed: number; errors: string[] }> { + if (!ids || ids.length === 0) { + throw new Error('未选择任何产品'); + } + + let success = 0; + let failed = 0; + const errors: string[] = []; + + for (const id of ids) { + try { + await this.deleteProduct(id); + success++; + } catch (error) { + failed++; + errors.push(`ID ${id}: ${error.message}`); + } + } + + return { success, failed, errors }; + } + + // 获取产品的库存组成列表(表关联版本) + async getProductComponents(productId: number): Promise { + // 条件判断:确保产品存在 + const product = await this.productModel.findOne({ where: { id: productId } }); + if (!product) throw new Error(`产品 ID ${productId} 不存在`); + + let components: ProductStockComponent[] = []; + // 条件判断(单品 simple 不持久化组成,按 SKU 动态返回单条组成) + if (product.type === 'single') { + const comp = new ProductStockComponent(); + comp.productId = productId; + comp.sku = product.sku; + comp.quantity = 1; + components = [comp]; + } else { + // 混装 bundle:返回已保存的 SKU 组成 + components = await this.productStockComponentModel.find({ where: { productId } }); + } + + // 获取所有组件的 SKU 列表 + const skus = components.map(c => c.sku); + if (skus.length === 0) { + return components; + } + + // 查询这些 SKU 的库存信息 + const stocks = await this.stockModel.find({ + where: { sku: In(skus) }, + }); + + // 获取所有相关的库存点 ID + const stockPointIds = [...new Set(stocks.map(s => s.stockPointId))]; + const stockPoints = await this.stockPointModel.find({ where: { id: In(stockPointIds) } }); + const stockPointMap = stockPoints.reduce((map, sp) => { + map[sp.id] = sp; + return map; + }, {}); + + // 将库存信息按 SKU 分组 + const stockMap = stocks.reduce((map, stock) => { + if (!map[stock.sku]) { + map[stock.sku] = []; + } + const stockPoint = stockPointMap[stock.stockPointId]; + if (stockPoint) { + map[stock.sku].push({ + name: stockPoint.name, + quantity: stock.quantity, + }); + } + return map; + }, {}); + + // 将库存信息附加到组件上 + const componentsWithStock = components.map(comp => { + return { + ...comp, + stock: stockMap[comp.sku] || [], + }; + }); + + return componentsWithStock; + } + + // 设置产品的库存组成(覆盖式,表关联版本) + async setProductComponents( + productId: number, + items: { sku: string; quantity: number }[] + ): Promise { + // 条件判断:确保产品存在 + const product = await this.productModel.findOne({ where: { id: productId } }); + if (!product) throw new Error(`产品 ID ${productId} 不存在`); + // 条件判断(单品 simple 不允许手动设置组成) + if (product.type === 'single') { + // 单品类型,直接清空关联的组成(如果有) + await this.productStockComponentModel.delete({ productId }); + return []; + } + + const validItems = (items || []) + .filter(i => i && i.sku && i.quantity && i.quantity > 0) + .map(i => ({ sku: String(i.sku), quantity: Number(i.quantity) })); + + // 删除旧的组成 + await this.productStockComponentModel.delete({ productId }); + + // 插入新的组成 + const created: ProductStockComponent[] = []; + for (const i of validItems) { + // 校验 SKU 格式,允许不存在库存但必须非空 + if (!i.sku || i.sku.trim().length === 0) { + throw new Error('SKU 不能为空'); + } + const comp = new ProductStockComponent(); + comp.productId = productId; + comp.sku = i.sku; + comp.quantity = i.quantity; + created.push(await this.productStockComponentModel.save(comp)); + } + return created; + } + + // 根据 SKU 自动绑定产品的库存组成(匹配所有相同 SKU 的库存,默认数量 1) + async autoBindComponentsBySku(productId: number): Promise { + // 条件判断:确保产品存在 + const product = await this.productModel.findOne({ where: { id: productId } }); + if (!product) throw new Error(`产品 ID ${productId} 不存在`); + // 按 SKU 自动绑定 + // 条件判断:simple 类型不持久化组成,直接返回单条基于 SKU 的组成 + if (product.type === 'single') { + const comp = new ProductStockComponent(); + comp.productId = productId; + comp.sku = product.sku; + comp.quantity = 1; // 默认数量 1 + return [comp]; + } + // bundle 类型:若不存在则持久化一条基于 SKU 的组成 + const exist = await this.productStockComponentModel.findOne({ where: { productId, sku: product.sku } }); + if (!exist) { + const comp = new ProductStockComponent(); + comp.productId = productId; + comp.sku = product.sku; + comp.quantity = 1; + await this.productStockComponentModel.save(comp); + } + return await this.getProductComponents(productId); + } + + // 站点SKU绑定:覆盖式绑定一组站点SKU到产品 + async bindSiteSkus(productId: number, codes: string[]): Promise { + const product = await this.productModel.findOne({ where: { id: productId } }); + if (!product) throw new Error(`产品 ID ${productId} 不存在`); + const normalized = (codes || []) + .map(c => String(c).trim()) + .filter(c => c.length > 0); + await this.productSiteSkuModel.delete({ productId }); + if (normalized.length === 0) return []; + const entities = normalized.map(code => { + const e = new ProductSiteSku(); + e.productId = productId; + e.siteSku = code; + return e; + }); + return await this.productSiteSkuModel.save(entities); + } + + // 站点SKU绑定:按单个 code 绑定到指定产品(若已有则更新归属) + async bindProductBySiteSku(code: string, productId: number): Promise { + const product = await this.productModel.findOne({ where: { id: productId } }); + if (!product) throw new Error(`产品 ID ${productId} 不存在`); + const skuCode = String(code || '').trim(); + if (!skuCode) throw new Error('站点SKU不能为空'); + const existing = await this.productSiteSkuModel.findOne({ where: { siteSku: skuCode } }); + if (existing) { + existing.productId = productId; + return await this.productSiteSkuModel.save(existing); + } + const e = new ProductSiteSku(); + e.productId = productId; + e.siteSku = skuCode; + return await this.productSiteSkuModel.save(e); + } + + // 重复定义的 getProductList 已合并到前面的实现(移除重复) + + async updatenameCn(id: number, nameCn: string): Promise { // 确认产品是否存在 const product = await this.productModel.findOneBy({ id }); if (!product) { @@ -227,221 +839,427 @@ export class ProductService { if (!product) { throw new Error(`产品 ID ${id} 不存在`); } - const productSku = product.sku; - // 查询 wp_product 表中是否存在与该 SKU 关联的产品 - const wpProduct = await this.wpProductModel - .createQueryBuilder('wp_product') - .where('JSON_CONTAINS(wp_product.constitution, :sku)', { - sku: JSON.stringify({ sku: productSku }), - }) - .getOne(); - if (wpProduct) { - throw new Error('无法删除,请先删除关联的WP产品'); - } - - const variation = await this.variationModel - .createQueryBuilder('variation') - .where('JSON_CONTAINS(variation.constitution, :sku)', { - sku: JSON.stringify({ sku: productSku }), - }) - .getOne(); - - if (variation) { - console.log(variation); - throw new Error('无法删除,请先删除关联的WP变体'); - } + // 不再阻塞于远端站点商品/变体的存在,删除仅按本地引用保护 // 删除产品 const result = await this.productModel.delete(id); return result.affected > 0; // `affected` 表示删除的行数 } - async hasProductsInCategory(categoryId: number): Promise { - const count = await this.productModel.count({ - where: { categoryId }, - }); - return count > 0; - } - async hasCategory(name: string, id?: string): Promise { - const where: any = { name }; + async hasAttribute( + dictName: string, + title: string, + id?: number + ): Promise { + const dict = await this.dictModel.findOne({ + where: { name: dictName }, + }); + if (!dict) { + return false; + } + const where: any = { title, dict: { id: dict.id } }; if (id) where.id = Not(id); - const count = await this.categoryModel.count({ + const count = await this.dictItemModel.count({ where, }); return count > 0; } - async getCategoryList( + async hasProductsInAttribute(attributeId: number): Promise { + const count = await this.productModel + .createQueryBuilder('product') + .innerJoin('product.attributes', 'attribute') + .where('attribute.id = :attributeId', { attributeId }) + .getCount(); + return count > 0; + } + + async getBrandList( pagination: PaginationParams, - name?: string - ): Promise { - const where: any = {}; - if (name) { - where.name = Like(`%${name}%`); + title?: string + ): Promise { + // 查找 'brand' 字典 + const brandDict = await this.dictModel.findOne({ + where: { name: 'brand' }, + }); + + // 如果字典不存在,则返回空 + if (!brandDict) { + return { + items: [], + total: 0, + ...pagination, + }; } - return await paginate(this.categoryModel, { pagination, where }); - } - async getCategoryAll(): Promise { - return await this.categoryModel.find(); - } - - async createCategory( - createCategoryDTO: CreateCategoryDTO - ): Promise { - const { name, unique_key } = createCategoryDTO; - const category = new Category(); - category.name = name; - category.unique_key = unique_key; - return await this.categoryModel.save(category); - } - - async updateCategory(id: number, updateCategory: UpdateCategoryDTO) { - // 确认产品是否存在 - const category = await this.categoryModel.findOneBy({ id }); - if (!category) { - throw new Error(`产品分类 ID ${id} 不存在`); + // 设置查询条件 + const where: any = { dict: { id: brandDict.id } }; + if (title) { + where.title = Like(`%${title}%`); } - // 更新产品 - await this.categoryModel.update(id, updateCategory); - // 返回更新后的产品 - return await this.categoryModel.findOneBy({ id }); + + // 分页查询 + return await paginate(this.dictItemModel, { pagination, where }); } - async deleteCategory(id: number): Promise { - // 检查产品是否存在 - const category = await this.categoryModel.findOneBy({ id }); - if (!category) { - throw new Error(`产品分类 ID ${id} 不存在`); + async getBrandAll(): Promise { + // 查找 'brand' 字典 + const brandDict = await this.dictModel.findOne({ + where: { name: 'brand' }, + }); + + // 如果字典不存在,则返回空数组 + if (!brandDict) { + return []; } - // 删除产品 - const result = await this.categoryModel.delete(id); + + // 返回所有品牌 + return this.dictItemModel.find({ where: { dict: { id: brandDict.id } } }); + } + + async createBrand(createBrandDTO: any): Promise { + const { title, name } = createBrandDTO; + + // 查找 'brand' 字典 + const brandDict = await this.dictModel.findOne({ + where: { name: 'brand' }, + }); + + // 如果字典不存在,则抛出错误 + if (!brandDict) { + throw new Error('品牌字典不存在'); + } + + // 创建新的品牌实例 + const brand = new DictItem(); + brand.title = title; + brand.name = name; + brand.dict = brandDict; + + // 保存到数据库 + return await this.dictItemModel.save(brand); + } + + async updateBrand(id: number, updateBrand: any) { + // 确认品牌是否存在 + const brand = await this.dictItemModel.findOneBy({ id }); + if (!brand) { + throw new Error(`品牌 ID ${id} 不存在`); + } + + // 更新品牌 + await this.dictItemModel.update(id, updateBrand); + + // 返回更新后的品牌 + return await this.dictItemModel.findOneBy({ id }); + } + + async deleteBrand(id: number): Promise { + // 检查品牌是否存在 + const brand = await this.dictItemModel.findOneBy({ id }); + if (!brand) { + throw new Error(`品牌 ID ${id} 不存在`); + } + + // 删除品牌 + const result = await this.dictItemModel.delete(id); return result.affected > 0; // `affected` 表示删除的行数 } - async hasProductsInFlavors(flavorsId: number): Promise { - const count = await this.productModel.count({ - where: { flavorsId }, - }); - return count > 0; - } - async hasFlavors(name: string, id?: string): Promise { - const where: any = { name }; - if (id) where.id = Not(id); - const count = await this.flavorsModel.count({ - where, - }); - return count > 0; - } + + async getFlavorsList( pagination: PaginationParams, - name?: string + title?: string ): Promise { - const where: any = {}; - if (name) { - where.name = Like(`%${name}%`); + const flavorsDict = await this.dictModel.findOne({ + where: { name: 'flavor' }, + }); + if (!flavorsDict) { + return { + items: [], + total: 0, + ...pagination, + }; } - return await paginate(this.flavorsModel, { pagination, where }); + const where: any = { dict: { id: flavorsDict.id } }; + if (title) { + where.title = Like(`%${title}%`); + } + return await paginate(this.dictItemModel, { pagination, where }); } async getFlavorsAll(): Promise { - return await this.flavorsModel.find(); + const flavorsDict = await this.dictModel.findOne({ + where: { name: 'flavor' }, + }); + if (!flavorsDict) { + return []; + } + return this.dictItemModel.find({ where: { dict: { id: flavorsDict.id } } }); } - async createFlavors(createFlavorsDTO: CreateFlavorsDTO): Promise { - const { name, unique_key } = createFlavorsDTO; - const flavors = new Flavors(); + async createFlavors(createFlavorsDTO: any): Promise { + const { title, name } = createFlavorsDTO; + const flavorsDict = await this.dictModel.findOne({ + where: { name: 'flavor' }, + }); + if (!flavorsDict) { + throw new Error('口味字典不存在'); + } + const flavors = new DictItem(); + flavors.title = title; flavors.name = name; - flavors.unique_key = unique_key; - return await this.flavorsModel.save(flavors); + flavors.dict = flavorsDict; + return await this.dictItemModel.save(flavors); } - async updateFlavors(id: number, updateFlavors: UpdateFlavorsDTO) { - // 确认产品是否存在 - const flavors = await this.flavorsModel.findOneBy({ id }); + async updateFlavors(id: number, updateFlavors: any) { + const flavors = await this.dictItemModel.findOneBy({ id }); if (!flavors) { throw new Error(`口味 ID ${id} 不存在`); } - // 更新产品 - await this.flavorsModel.update(id, updateFlavors); - // 返回更新后的产品 - return await this.flavorsModel.findOneBy({ id }); + await this.dictItemModel.update(id, updateFlavors); + return await this.dictItemModel.findOneBy({ id }); } async deleteFlavors(id: number): Promise { - // 检查产品是否存在 - const flavors = await this.flavorsModel.findOneBy({ id }); + const flavors = await this.dictItemModel.findOneBy({ id }); if (!flavors) { throw new Error(`口味 ID ${id} 不存在`); } - // 删除产品 - const result = await this.flavorsModel.delete(id); - return result.affected > 0; // `affected` 表示删除的行数 - } - async hasProductsInStrength(strengthId: number): Promise { - const count = await this.productModel.count({ - where: { strengthId }, - }); - return count > 0; + const result = await this.dictItemModel.delete(id); + return result.affected > 0; } - async hasStrength(name: string, id?: string): Promise { - const where: any = { name }; + // size 尺寸相关方法 + async getSizeList( + pagination: PaginationParams, + title?: string + ): Promise { + // 查找 'size' 字典(用于尺寸) + const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } }); + // 条件判断(如果字典不存在则返回空分页) + if (!sizeDict) { + return { + items: [], + total: 0, + ...pagination, + } as any; + } + // 构建 where 条件(按标题模糊搜索) + const where: any = { dict: { id: sizeDict.id } }; + if (title) { + where.title = Like(`%${title}%`); + } + // 分页查询(复用通用分页工具) + return await paginate(this.dictItemModel, { pagination, where }); + } + + async getSizeAll(): Promise { + // 查找 'size' 字典(获取所有尺寸项) + const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } }); + // 条件判断(如果字典不存在返回空数组) + if (!sizeDict) { + return [] as any; + } + return this.dictItemModel.find({ where: { dict: { id: sizeDict.id } } }) as any; + } + + async createSize(createSizeDTO: any): Promise { + const { title, name } = createSizeDTO; + // 获取 size 字典(用于挂载尺寸项) + const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } }); + // 条件判断(尺寸字典不存在则抛错) + if (!sizeDict) { + throw new Error('尺寸字典不存在'); + } + // 创建字典项(保存尺寸名称与唯一标识) + const size = new DictItem(); + size.title = title; + size.name = name; + size.dict = sizeDict; + return await this.dictItemModel.save(size); + } + + async updateSize(id: number, updateSize: any) { + // 先查询(确保尺寸项存在) + const size = await this.dictItemModel.findOneBy({ id }); + // 条件判断(不存在则报错) + if (!size) { + throw new Error(`尺寸 ID ${id} 不存在`); + } + // 更新(写入变更字段) + await this.dictItemModel.update(id, updateSize); + // 返回最新(再次查询返回) + return await this.dictItemModel.findOneBy({ id }); + } + + async deleteSize(id: number): Promise { + // 先查询(确保尺寸项存在) + const size = await this.dictItemModel.findOneBy({ id }); + // 条件判断(不存在则报错) + if (!size) { + throw new Error(`尺寸 ID ${id} 不存在`); + } + // 删除(执行删除并返回受影响行数是否>0) + const result = await this.dictItemModel.delete(id); + return result.affected > 0; + } + + + async hasStrength(title: string, id?: string): Promise { + const strengthDict = await this.dictModel.findOne({ + where: { name: 'strength' }, + }); + if (!strengthDict) { + return false; + } + const where: any = { title, dict: { id: strengthDict.id } }; if (id) where.id = Not(id); - const count = await this.strengthModel.count({ + const count = await this.dictItemModel.count({ where, }); return count > 0; } async getStrengthList( pagination: PaginationParams, - name?: string + title?: string ): Promise { - const where: any = {}; - if (name) { - where.name = Like(`%${name}%`); + const strengthDict = await this.dictModel.findOne({ + where: { name: 'strength' }, + }); + if (!strengthDict) { + return { + items: [], + total: 0, + ...pagination, + }; } - return await paginate(this.strengthModel, { pagination, where }); + const where: any = { dict: { id: strengthDict.id } }; + if (title) { + where.title = Like(`%${title}%`); + } + return await paginate(this.dictItemModel, { pagination, where }); } async getStrengthAll(): Promise { - return await this.strengthModel.find(); - } - - async createStrength( - createStrengthDTO: CreateStrengthDTO - ): Promise { - const { name, unique_key } = createStrengthDTO; - const strength = new Strength(); - strength.name = name; - strength.unique_key = unique_key; - return await this.strengthModel.save(strength); - } - - async updateStrength(id: number, updateStrength: UpdateStrengthDTO) { - // 确认产品是否存在 - const strength = await this.strengthModel.findOneBy({ id }); - if (!strength) { - throw new Error(`口味 ID ${id} 不存在`); + const strengthDict = await this.dictModel.findOne({ + where: { name: 'strength' }, + }); + if (!strengthDict) { + return []; } - // 更新产品 - await this.strengthModel.update(id, updateStrength); - // 返回更新后的产品 - return await this.strengthModel.findOneBy({ id }); + return this.dictItemModel.find({ where: { dict: { id: strengthDict.id } } }); + } + + async createStrength(createStrengthDTO: any): Promise { + const { title, name } = createStrengthDTO; + const strengthDict = await this.dictModel.findOne({ + where: { name: 'strength' }, + }); + if (!strengthDict) { + throw new Error('规格字典不存在'); + } + const strength = new DictItem(); + strength.title = title; + strength.name = name; + strength.dict = strengthDict; + return await this.dictItemModel.save(strength); + } + + // 通用属性:分页获取指定字典的字典项 + async getAttributeList( + dictName: string, + pagination: PaginationParams, + name?: string + ): Promise { + const dict = await this.dictModel.findOne({ where: { name: dictName } }); + if (!dict) return { items: [], total: 0, ...pagination } as any; + const where: any = { dict: { id: dict.id } }; + if (name) where.title = Like(`%${name}%`); + const [items, total] = await this.dictItemModel.findAndCount({ + where, + skip: (pagination.current - 1) * pagination.pageSize, + take: pagination.pageSize, + order: { sort: 'ASC', id: 'DESC' }, + relations: ['dict'], + }); + return { items, total, ...pagination } as any; + } + + // 通用属性:获取指定字典的全部字典项 + async getAttributeAll(dictName: string): Promise { + const dict = await this.dictModel.findOne({ where: { name: dictName } }); + if (!dict) return []; + return this.dictItemModel.find({ + where: { dict: { id: dict.id } }, + order: { sort: 'ASC', id: 'DESC' }, + relations: ['dict'], + }); + } + + // 通用属性:创建字典项 + async createAttribute( + dictName: string, + payload: { title: string; name: string; image?: string; shortName?: string } + ): Promise { + const dict = await this.dictModel.findOne({ where: { name: dictName } }); + if (!dict) throw new Error(`字典 ${dictName} 不存在`); + const exists = await this.dictItemModel.findOne({ + where: { name: payload.name, dict: { id: dict.id } }, + relations: ['dict'], + }); + if (exists) throw new Error('字典项已存在'); + const item = new DictItem(); + item.title = payload.title; + item.name = payload.name; + item.image = payload.image; + item.shortName = payload.shortName; + item.dict = dict; + return await this.dictItemModel.save(item); + } + + // 通用属性:更新字典项 + async updateAttribute( + id: number, + payload: { title?: string; name?: string; image?: string; shortName?: string } + ): Promise { + const item = await this.dictItemModel.findOne({ where: { id } }); + if (!item) throw new Error('字典项不存在'); + if (payload.title !== undefined) item.title = payload.title; + if (payload.name !== undefined) item.name = payload.name; + if (payload.image !== undefined) item.image = payload.image; + if (payload.shortName !== undefined) item.shortName = payload.shortName; + return await this.dictItemModel.save(item); + } + + // 通用属性:删除字典项(若存在产品关联则禁止删除) + async deleteAttribute(id: number): Promise { + const hasProducts = await this.hasProductsInAttribute(id); + if (hasProducts) throw new Error('当前字典项存在关联产品,无法删除'); + await this.dictItemModel.delete({ id }); + } + + async updateStrength(id: number, updateStrength: any) { + const strength = await this.dictItemModel.findOneBy({ id }); + if (!strength) { + throw new Error(`规格 ID ${id} 不存在`); + } + await this.dictItemModel.update(id, updateStrength); + return await this.dictItemModel.findOneBy({ id }); } async deleteStrength(id: number): Promise { - // 检查产品是否存在 - const strength = await this.strengthModel.findOneBy({ id }); + const strength = await this.dictItemModel.findOneBy({ id }); if (!strength) { - throw new Error(`口味 ID ${id} 不存在`); + throw new Error(`规格 ID ${id} 不存在`); } - // 删除产品 - const result = await this.flavorsModel.delete(id); - return result.affected > 0; // `affected` 表示删除的行数 + const result = await this.dictItemModel.delete(id); + return result.affected > 0; } async batchSetSku(skus: { productId: number; sku: string }[]) { @@ -458,7 +1276,7 @@ export class ProductService { throw new Error(`以下 SKU 已存在: ${existingSkus.join(', ')}`); } - // 遍历检查产品 ID 是否存在,并更新 sku + // 遍历检查产品 ID 是否存在,并更新 sku for (const { productId, sku } of skus) { const product = await this.productModel.findOne({ where: { id: productId }, @@ -473,4 +1291,389 @@ export class ProductService { return `成功更新 ${skus.length} 个 sku`; } + + // 将单条 CSV 记录转换为数据对象 + transformCsvRecordToData(rec: any): CreateProductDTO & { sku: string } | null { + // 必须包含 sku + const sku: string = (rec.sku || '').trim(); + if (!sku) { + return null; + } + + // 辅助函数:处理空字符串为 undefined + const val = (v: any) => { + if (v === undefined || v === null) return undefined; + const s = String(v).trim(); + return s === '' ? undefined : s; + }; + + // 辅助函数:处理数字 + const num = (v: any) => { + const s = val(v); + return s ? Number(s) : undefined; + }; + + // 解析属性字段(分号分隔多值) + const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []); + + // 将属性解析为 DTO 输入 + const attributes: any[] = []; + + // 处理动态属性字段 (attribute_*) + for (const key of Object.keys(rec)) { + if (key.startsWith('attribute_')) { + const dictName = key.replace('attribute_', ''); + if (dictName) { + const list = parseList(rec[key]); + for (const item of list) attributes.push({ dictName, title: item }); + } + } + } + + return { + sku, + name: val(rec.name), + nameCn: val(rec.nameCn), + description: val(rec.description), + price: num(rec.price), + promotionPrice: num(rec.promotionPrice), + type: val(rec.type), + siteSkus: rec.siteSkus ? String(rec.siteSkus).split(',').map(s => s.trim()).filter(Boolean) : undefined, + + attributes: attributes.length > 0 ? attributes : undefined, + } as any; + } + + // 准备创建产品的 DTO, 处理类型转换和默认值 + prepareCreateProductDTO(data: any): CreateProductDTO { + const dto = new CreateProductDTO(); + // 基础字段赋值 + dto.name = data.name; + dto.nameCn = data.nameCn; + dto.description = data.description; + dto.sku = data.sku; + if (data.siteSkus) dto.siteSkus = data.siteSkus; + + // 数值类型转换 + if (data.price !== undefined) dto.price = Number(data.price); + if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice); + + if (data.categoryId !== undefined) dto.categoryId = Number(data.categoryId); + + // 默认值和特殊处理 + + dto.attributes = Array.isArray(data.attributes) ? data.attributes : []; + + // 如果有组件信息,透传 + dto.type = data.type || 'single'; + + return dto; + } + + // 准备更新产品的 DTO, 处理类型转换 + prepareUpdateProductDTO(data: any): UpdateProductDTO { + const dto = new UpdateProductDTO(); + + if (data.name !== undefined) dto.name = data.name; + if (data.nameCn !== undefined) dto.nameCn = data.nameCn; + if (data.description !== undefined) dto.description = data.description; + if (data.sku !== undefined) dto.sku = data.sku; + if (data.siteSkus !== undefined) dto.siteSkus = data.siteSkus; + + if (data.price !== undefined) dto.price = Number(data.price); + if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice); + + if (data.categoryId !== undefined) dto.categoryId = Number(data.categoryId); + + if (data.type !== undefined) dto.type = data.type; + if (data.attributes !== undefined) dto.attributes = data.attributes; + if (data.components !== undefined) dto.components = data.components; + + return dto; + } + + // 将单个产品转换为 CSV 行数组 + transformProductToCsvRow( + p: Product, + sortedDictNames: string[], + maxComponentCount: number + ): string[] { + // CSV 字段转义,处理逗号与双引号 + const esc = (v: any) => { + const s = v === undefined || v === null ? '' : String(v); + const needsQuote = /[",\n]/.test(s); + const escaped = s.replace(/"/g, '""'); + return needsQuote ? `"${escaped}"` : escaped; + }; + + // 将属性列表转为字典名到显示值的映射 + const pickAttr = (prod: Product, key: string) => { + const list = (prod.attributes || []).filter(a => a?.dict?.name === key); + if (list.length === 0) return ''; + // 多个值使用分号分隔 + return list.map(a => a.title || a.name).join(';'); + }; + + // 基础数据 + const rowData = [ + esc(p.sku), + esc(p.siteSkus ? p.siteSkus.map(s => s.siteSku).join(',') : ''), + esc(p.name), + esc(p.nameCn), + esc(p.price), + esc(p.promotionPrice), + esc(p.type), + + esc(p.description), + ]; + + // 属性数据 + for (const dictName of sortedDictNames) { + rowData.push(esc(pickAttr(p, dictName))); + } + + // 组件数据 + const components = p.components || []; + for (let i = 0; i < maxComponentCount; i++) { + const comp = components[i]; + if (comp) { + rowData.push(esc(comp.sku)); + rowData.push(esc(comp.quantity)); + } else { + rowData.push(''); + rowData.push(''); + } + } + + return rowData; + } + + // 导出所有产品为 CSV 文本 + async exportProductsCSV(): Promise { + // 查询所有产品及其属性(包含字典关系)和组成 + const products = await this.productModel.find({ + relations: ['attributes', 'attributes.dict', 'components', 'siteSkus'], + order: { id: 'ASC' }, + }); + + // 1. 收集所有动态属性的 dictName + const dictNames = new Set(); + // 2. 收集最大的组件数量 + let maxComponentCount = 0; + + for (const p of products) { + if (p.attributes) { + for (const attr of p.attributes) { + if (attr.dict && attr.dict.name) { + dictNames.add(attr.dict.name); + } + } + } + if (p.components) { + if (p.components.length > maxComponentCount) { + maxComponentCount = p.components.length; + } + } + } + + const sortedDictNames = Array.from(dictNames).sort(); + + // 定义 CSV 表头(与导入字段一致) + const baseHeaders = [ + 'sku', + 'siteSkus', + 'name', + 'nameCn', + 'price', + 'promotionPrice', + 'type', + + 'description', + ]; + + // 动态属性表头 + const attributeHeaders = sortedDictNames.map(name => `attribute_${name}`); + + // 动态组件表头 + const componentHeaders = []; + for (let i = 1; i <= maxComponentCount; i++) { + componentHeaders.push(`component_${i}_sku`); + componentHeaders.push(`component_${i}_quantity`); + } + + const allHeaders = [...baseHeaders, ...attributeHeaders, ...componentHeaders]; + + const rows: string[] = []; + rows.push(allHeaders.join(',')); + + for (const p of products) { + const rowData = this.transformProductToCsvRow(p, sortedDictNames, maxComponentCount); + rows.push(rowData.join(',')); + } + + return rows.join('\n'); + } + + // 从 CSV 导入产品;存在则更新,不存在则创建 + async importProductsCSV(file: any): Promise<{ created: number; updated: number; errors: string[] }> { + let buffer: Buffer; + if (Buffer.isBuffer(file)) { + buffer = file; + } else if (file?.data) { + if (typeof file.data === 'string') { + buffer = fs.readFileSync(file.data); + } else { + buffer = file.data; + } + } else { + throw new Error('无效的文件输入'); + } + + // 解析 CSV(使用 csv-parse/sync 按表头解析) + let records: any[] = []; + try { + records = await new Promise((resolve, reject) => { + parse(buffer, { + columns: true, + skip_empty_lines: true, + trim: true, + bom: true, + }, (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }) + console.log('Parsed records count:', records.length); + if (records.length > 0) { + console.log('First record keys:', Object.keys(records[0])); + } + } catch (e: any) { + return { created: 0, updated: 0, errors: [`CSV 解析失败:${e?.message || e}`] }; + } + + let created = 0; + let updated = 0; + const errors: string[] = []; + + // 逐条处理记录 + for (const rec of records) { + try { + const data = this.transformCsvRecordToData(rec); + if (!data) { + errors.push('缺少 SKU 的记录已跳过'); + continue; + } + const { sku } = data; + + // 查找现有产品 + const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] }); + + if (!exist) { + // 创建新产品 + const createDTO = this.prepareCreateProductDTO(data); + await this.createProduct(createDTO); + created += 1; + } else { + // 更新产品 + const updateDTO = this.prepareUpdateProductDTO(data); + await this.updateProduct(exist.id, updateDTO); + updated += 1; + } + } catch (e: any) { + errors.push(`产品${rec?.sku}导入失败:${e?.message || String(e)}`); + } + } + + return { created, updated, errors }; + } + + // 将库存记录的 sku 添加到产品单品中 + async syncStockToProduct(): Promise<{ added: number; errors: string[] }> { + // 1. 获取所有库存记录的 SKU (去重) + const stockSkus = await this.stockModel + .createQueryBuilder('stock') + .select('DISTINCT(stock.sku)', 'sku') + .getRawMany(); + + const skus = stockSkus.map(s => s.sku).filter(Boolean); + let added = 0; + const errors: string[] = []; + + // 2. 遍历 SKU,检查并添加 + for (const sku of skus) { + try { + const exist = await this.productModel.findOne({ where: { sku } }); + if (!exist) { + const product = new Product(); + product.sku = sku; + product.name = sku; // 默认使用 SKU 作为名称 + product.type = 'single'; + product.price = 0; + product.promotionPrice = 0; + await this.productModel.save(product); + added++; + } + } catch (error) { + errors.push(`SKU ${sku} 添加失败: ${error.message}`); + } + } + + return { added, errors }; + } + + // 获取产品的站点SKU列表 + async getProductSiteSkus(productId: number): Promise { + return this.productSiteSkuModel.find({ + where: { productId }, + relations: ['product'], + order: { createdAt: 'ASC' } + }); + } + + // 根据ID获取产品详情(包含站点SKU) + async getProductById(id: number): Promise { + const product = await this.productModel.findOne({ + where: { id }, + relations: ['category', 'attributes', 'attributes.dict', 'siteSkus', 'components'] + }); + + if (!product) { + throw new Error(`产品 ID ${id} 不存在`); + } + + // 根据类型填充组成信息 + if (product.type === 'single') { + // 单品不持久化组成,这里仅返回一个基于 SKU 的虚拟组成 + const component = new ProductStockComponent(); + component.productId = product.id; + component.sku = product.sku; + component.quantity = 1; + product.components = [component]; + } else { + // 混装商品返回持久化的 SKU 组成 + product.components = await this.productStockComponentModel.find({ + where: { productId: product.id }, + }); + } + + return product; + } + + // 根据站点SKU查询产品 + async findProductBySiteSku(siteSku: string): Promise { + const siteSkuEntity = await this.productSiteSkuModel.findOne({ + where: { siteSku }, + relations: ['product'] + }); + + if (!siteSkuEntity) { + throw new Error(`站点SKU ${siteSku} 不存在`); + } + + // 获取完整的产品信息,包含所有关联数据 + return this.getProductById(siteSkuEntity.product.id); + } } diff --git a/src/service/shopyy.service.ts b/src/service/shopyy.service.ts new file mode 100644 index 0000000..d6eaa4b --- /dev/null +++ b/src/service/shopyy.service.ts @@ -0,0 +1,810 @@ +import { Inject, Provide } from '@midwayjs/core'; +import axios, { AxiosRequestConfig } from 'axios'; +import * as fs from 'fs'; +import * as FormData from 'form-data'; +import { SiteService } from './site.service'; +import { Site } from '../entity/site.entity'; +import { UnifiedReviewDTO } from '../dto/site-api.dto'; +import { ShopyyReview } from '../dto/shopyy.dto'; + +/** + * ShopYY平台服务实现 + */ +@Provide() +export class ShopyyService { + /** + * 获取ShopYY评论列表 + * @param site 站点配置 + * @param params 查询参数 + * @returns 分页评论列表 + */ + async getReviews(site: any, params: any): Promise { + // ShopYY API: GET /reviews/list + const { items, total, totalPages, page, per_page } = + await this.fetchResourcePaged(site, 'comments/list', params); + return { + items: items.map(this.mapReview), + total, + totalPages, + page, + per_page + }; + } + mapReview(review: ShopyyReview): UnifiedReviewDTO { + return { + id: review.id, + product_id: review.product_id, + // countryId: mapReview.country_id, + // ip: mapReview.ip, + rating: review.star, + author: review.customer_name, + email: review.customer_email, + // reply_content: mapReview.reply_content, + content: review.content, + status: review.status.toString(), + // is_image: mapReview.is_image, + // images: mapReview.images, + date_modified: review.updated_at ? new Date(review.updated_at * 1000).toISOString() : undefined, + date_created: review.created_at ? new Date(review.created_at * 1000).toISOString() : undefined, + raw: review + } + } + + /** + * 获取单个ShopYY评论 + * @param site 站点配置 + * @param reviewId 评论ID + * @returns 评论详情 + */ + async getReview(site: any, reviewId: string | number): Promise { + // ShopYY API: GET /reviews/{id} + const response = await this.request(site, `comments/${reviewId}`, 'GET'); + return response.data; + } + /** + * 创建ShopYY评论 + * @param site 站点配置 + * @param data 评论数据 + * @returns 创建结果 + */ + async createReview(site: any, data: any): Promise { + // ShopYY API: POST /reviews/create + const response = await this.request(site, 'comments/create', 'POST', data); + return response.data; + } + /** + * 更新ShopYY评论 + * @param site 站点配置 + * @param reviewId 评论ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateReview(site: any, reviewId: string | number, data: any): Promise { + // ShopYY API: POST /reviews/update/{id} + const response = await this.request(site, `comments/update/${reviewId}`, 'POST', data); + return response.data; + } + /** + * 删除ShopYY评论 + * @param site 站点配置 + * @param reviewId 评论ID + * @returns 删除结果 + */ + async deleteReview(site: any, reviewId: string | number): Promise { + try { + // ShopYY API: DELETE /reviews/delete/{id} + await this.request(site, `comments/delete/${reviewId}`, 'DELETE'); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '删除ShopYY评论失败'; + throw new Error(`删除ShopYY评论失败: ${errorMessage}`); + } + } + fetchMediaPaged(site: any, params: Record): Promise<{ items: any[]; total: number; totalPages: number; page: number; per_page: number; }> { + throw new Error('Method not implemented.'); + } + convertMediaToWebp(siteId: number, mediaIds: Array): Promise<{ converted: any[]; failed: Array<{ id: number | string; error: string; }>; }> { + throw new Error('Method not implemented.'); + } + getSubscriptions?(siteId: number): Promise { + throw new Error('Method not implemented.'); + } + getCustomers(site: any): Promise { + throw new Error('Method not implemented.'); + } + @Inject() + private readonly siteService: SiteService; + + /** + * 构建ShopYY API请求URL + * @param baseUrl 基础URL + * @param endpoint API端点 + * @returns 完整URL + */ + private buildURL(baseUrl: string, endpoint: string): string { + // ShopYY API URL格式:https://{shop}.shopyy.com/openapi/{version}/{endpoint} + const base = baseUrl.replace(/\/$/, ''); + const end = endpoint.replace(/^\//, ''); + return `${base}/${end}`; + } + + /** + * 构建ShopYY API请求头 + * @param site 站点配置 + * @returns 请求头 + */ + private buildHeaders(site: Site): Record { + if (!site?.token) { + throw new Error(`获取站点${site?.name}数据,但失败,因为未设置站点令牌配置`) + } + return { + 'Content-Type': 'application/json', + token: site.token || '' + }; + } + + /** + * 发送ShopYY API请求 + * @param site 站点配置 + * @param endpoint API端点 + * @param method 请求方法 + * @param data 请求数据 + * @param params 请求参数 + * @returns 响应数据 + */ + private async request(site: any, endpoint: string, method: string = 'GET', data: any = null, params: any = null): Promise { + const url = this.buildURL(site.apiUrl, endpoint); + const headers = this.buildHeaders(site); + + const config: AxiosRequestConfig = { + url, + method, + headers, + params, + data + }; + + try { + const response = await axios(config); + return response.data; + } catch (error) { + console.error('ShopYY API请求失败:', error.response?.data || error.message); + throw error; + } + } + + /** + * 通用分页获取资源 + */ + public async fetchResourcePaged(site: any, endpoint: string, params: Record = {}) { + const page = Number(params.page || 1); + const limit = Number(params.per_page ?? 20); + const where = params.where && typeof params.where === 'object' ? params.where : {}; + let orderby: string | undefined = params.orderby; + let order: 'asc' | 'desc' | undefined = params.orderDir as any; + if (!orderby && params.order && typeof params.order === 'object') { + const entries = Object.entries(params.order as Record); + if (entries.length > 0) { + const [field, dir] = entries[0]; + orderby = field; + order = String(dir).toLowerCase() === 'desc' ? 'desc' : 'asc'; + } + } + // 映射统一入参到平台入参 + const requestParams = { + ...where, + ...(params.search ? { search: params.search } : {}), + ...(params.status ? { status: params.status } : {}), + ...(orderby ? { orderby } : {}), + ...(order ? { order } : {}), + page, + limit + }; + const response = await this.request(site, endpoint, 'GET', null, requestParams); + if (response?.code !== 0) { + throw new Error(response?.msg) + } + return { + items: (response.data.list || []) as T[], + total: response.data?.paginate?.total || 0, + totalPages: response.data?.paginate?.pageTotal || 0, + page: response.data?.paginate?.current || requestParams.page, + per_page: response.data?.paginate?.pagesize || requestParams.limit, + }; + } + + /** + * 获取ShopYY产品列表 + * @param site 站点配置 + * @param page 页码 + * @param pageSize 每页数量 + * @returns 分页产品列表 + */ + async getProducts(site: any, page: number = 1, pageSize: number = 100): Promise { + // ShopYY API: GET /products + // 通过 fields 参数指定需要返回的字段,确保 handle 等关键信息被包含 + const response = await this.request(site, 'products', 'GET', null, { + page, + page_size: pageSize, + fields: 'id,name,sku,handle,status,type,stock_status,stock_quantity,images,regular_price,sale_price,tags,variations' + }); + + return { + items: response.data || [], + total: response.meta?.pagination?.total || 0, + totalPages: response.meta?.pagination?.total_pages || 0, + page: response.meta?.pagination?.current_page || page, + per_page: response.meta?.pagination?.per_page || pageSize + }; + } + + /** + * 获取单个ShopYY产品 + * @param site 站点配置 + * @param productId 产品ID + * @returns 产品详情 + */ + async getProduct(site: any, productId: string | number): Promise { + // ShopYY API: GET /products/{id} + const response = await this.request(site, `products/${productId}`, 'GET'); + return response.data; + } + + /** + * 获取ShopYY产品变体列表 + * @param site 站点配置 + * @param productId 产品ID + * @param page 页码 + * @param pageSize 每页数量 + * @returns 分页变体列表 + */ + async getVariations(site: any, productId: number, page: number = 1, pageSize: number = 100): Promise { + // ShopYY API: GET /products/{id}/variations + const response = await this.request(site, `products/${productId}/variations`, 'GET', null, { + page, + page_size: pageSize + }); + + return { + items: response.data || [], + total: response.meta?.pagination?.total || 0, + totalPages: response.meta?.pagination?.total_pages || 0, + page: response.meta?.pagination?.current_page || page, + per_page: response.meta?.pagination?.per_page || pageSize + }; + } + + /** + * 获取ShopYY产品变体详情 + * @param site 站点配置 + * @param productId 产品ID + * @param variationId 变体ID + * @returns 变体详情 + */ + async getVariation(site: any, productId: number, variationId: number): Promise { + // ShopYY API: GET /products/{product_id}/variations/{variation_id} + const response = await this.request(site, `products/${productId}/variations/${variationId}`, 'GET'); + return response.data; + } + + /** + * 获取ShopYY订单列表 + * @param site 站点配置或站点ID + * @param page 页码 + * @param pageSize 每页数量 + * @returns 分页订单列表 + */ + async getOrders(site: any | number, page: number = 1, pageSize: number = 100): Promise { + // 如果传入的是站点ID,则获取站点配置 + const siteConfig = typeof site === 'number' ? await this.siteService.get(site) : site; + + // ShopYY API: GET /orders + const response = await this.request(siteConfig, 'orders', 'GET', null, { + page, + page_size: pageSize + }); + + return { + items: response.data || [], + total: response.meta?.pagination?.total || 0, + totalPages: response.meta?.pagination?.total_pages || 0, + page: response.meta?.pagination?.current_page || page, + per_page: response.meta?.pagination?.per_page || pageSize + }; + } + + /** + * 获取ShopYY订单详情 + * @param siteId 站点ID + * @param orderId 订单ID + * @returns 订单详情 + */ + async getOrder(siteId: string, orderId: string): Promise { + const site = await this.siteService.get(Number(siteId)); + + // ShopYY API: GET /orders/{id} + const response = await this.request(site, `orders/${orderId}`, 'GET'); + return response.data; + } + + /** + * 创建ShopYY产品 + * @param site 站点配置 + * @param data 产品数据 + * @returns 创建结果 + */ + async createProduct(site: any, data: any): Promise { + // ShopYY API: POST /products + const response = await this.request(site, 'products', 'POST', data); + return response.data; + } + + /** + * 更新ShopYY产品 + * @param site 站点配置 + * @param productId 产品ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateProduct(site: any, productId: string, data: any): Promise { + try { + // ShopYY API: PUT /products/{id} + await this.request(site, `products/${productId}`, 'PUT', data); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '更新ShopYY产品失败'; + throw new Error(`更新ShopYY产品失败: ${errorMessage}`); + } + } + + /** + * 更新ShopYY产品状态 + * @param site 站点配置 + * @param productId 产品ID + * @param status 产品状态 + * @param stockStatus 库存状态 + * @returns 更新结果 + */ + async updateProductStatus(site: any, productId: string, status: string, stockStatus: string): Promise { + // ShopYY产品状态映射 + const shopyyStatus = status === 'publish' ? 1 : 0; + const shopyyStockStatus = stockStatus === 'instock' ? 1 : 0; + + try { + await this.request(site, `products/${productId}`, 'PUT', { + status: shopyyStatus, + stock_status: shopyyStockStatus + }); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '更新ShopYY产品状态失败'; + throw new Error(`更新ShopYY产品状态失败: ${errorMessage}`); + } + } + + /** + * 更新ShopYY产品变体 + * @param site 站点配置 + * @param productId 产品ID + * @param variationId 变体ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateVariation(site: any, productId: string, variationId: string, data: any): Promise { + try { + // ShopYY API: PUT /products/{product_id}/variations/{variation_id} + await this.request(site, `products/${productId}/variations/${variationId}`, 'PUT', data); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '更新ShopYY产品变体失败'; + throw new Error(`更新ShopYY产品变体失败: ${errorMessage}`); + } + } + + /** + * 更新ShopYY订单 + * @param site 站点配置 + * @param orderId 订单ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateOrder(site: any, orderId: string, data: Record): Promise { + try { + // ShopYY API: PUT /orders/{id} + await this.request(site, `orders/${orderId}`, 'PUT', data); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '更新ShopYY订单失败'; + throw new Error(`更新ShopYY订单失败: ${errorMessage}`); + } + } + + /** + * 创建ShopYY物流信息 + * @param site 站点配置 + * @param orderId 订单ID + * @param data 物流数据 + * @returns 创建结果 + */ + async createShipment(site: any, orderId: string, data: any): Promise { + // ShopYY API: POST /orders/{id}/shipments + const shipmentData = { + tracking_number: data.tracking_number, + carrier_code: data.carrier_code, + carrier_name: data.carrier_name, + shipping_method: data.shipping_method + }; + + const response = await this.request(site, `orders/${orderId}/shipments`, 'POST', shipmentData); + return response.data; + } + + /** + * 删除ShopYY物流信息 + * @param site 站点配置 + * @param orderId 订单ID + * @param trackingId 物流跟踪ID + * @returns 删除结果 + */ + async deleteShipment(site: any, orderId: string, trackingId: string): Promise { + try { + // ShopYY API: DELETE /orders/{order_id}/shipments/{tracking_id} + await this.request(site, `orders/${orderId}/shipments/${trackingId}`, 'DELETE'); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '删除ShopYY物流信息失败'; + throw new Error(`删除ShopYY物流信息失败: ${errorMessage}`); + } + } + + /** + * 获取ShopYY订单备注 + * @param site 站点配置 + * @param orderId 订单ID + * @param page 页码 + * @param pageSize 每页数量 + * @returns 分页订单备注列表 + */ + async getOrderNotes(site: any, orderId: string | number, page: number = 1, pageSize: number = 100): Promise { + // ShopYY API: GET /orders/{id}/notes + const response = await this.request(site, `orders/${orderId}/notes`, 'GET', null, { + page, + page_size: pageSize + }); + + return { + items: response.data || [], + total: response.meta?.pagination?.total || 0, + totalPages: response.meta?.pagination?.total_pages || 0, + page: response.meta?.pagination?.current_page || page, + per_page: response.meta?.pagination?.per_page || pageSize + }; + } + + /** + * 创建ShopYY订单备注 + * @param site 站点配置 + * @param orderId 订单ID + * @param data 备注数据 + * @returns 创建结果 + */ + async createOrderNote(site: any, orderId: string | number, data: any): Promise { + // ShopYY API: POST /orders/{id}/notes + const noteData = { + note: data.note, + is_customer_note: data.is_customer_note || false + }; + const response = await this.request(site, `orders/${orderId}/notes`, 'POST', noteData); + return response.data; + } + + /** + * 创建ShopYY订单 + * @param site 站点配置 + * @param data 订单数据 + * @returns 创建结果 + */ + async createOrder(site: any, data: any): Promise { + // ShopYY API: POST /orders + const response = await this.request(site, 'orders', 'POST', data); + return response.data; + } + + /** + * 删除ShopYY订单 + * @param site 站点配置 + * @param orderId 订单ID + * @returns 删除结果 + */ + async deleteOrder(site: any, orderId: string | number): Promise { + try { + // ShopYY API: DELETE /orders/{id} + await this.request(site, `orders/${orderId}`, 'DELETE'); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '删除ShopYY订单失败'; + throw new Error(`删除ShopYY订单失败: ${errorMessage}`); + } + } + + /** + * 批量处理ShopYY产品 + * @param site 站点配置 + * @param data 批量操作数据 + * @returns 处理结果 + */ + async batchProcessProducts(site: any, data: { create?: any[]; update?: any[]; delete?: any[] }): Promise { + // ShopYY API: POST /products/batch + const response = await this.request(site, 'products/batch', 'POST', data); + return response.data; + } + + /** + * 获取ShopYY客户列表 + * @param site 站点配置 + * @param params 查询参数 + * @returns 分页客户列表 + */ + async fetchCustomersPaged(site: any, params: any): Promise { + // ShopYY API: GET /customers + const { items, total, totalPages, page, per_page } = + await this.fetchResourcePaged(site, 'customers/list', params); + return { + items, + total, + totalPages, + page, + per_page + }; + } + + /** + * 获取单个ShopYY客户 + * @param site 站点配置 + * @param customerId 客户ID + * @returns 客户详情 + */ + async getCustomer(site: any, customerId: string | number): Promise { + // ShopYY API: GET /customers/{id} + const response = await this.request(site, `customers/${customerId}`, 'GET'); + return response.data; + } + + /** + * 创建ShopYY客户 + * @param site 站点配置 + * @param data 客户数据 + * @returns 创建结果 + */ + async createCustomer(site: any, data: any): Promise { + // ShopYY API: POST /customers + const customerData = { + firstname: data.first_name || '', + lastname: data.last_name || '', + email: data.email || '', + phone: data.phone || '', + password: data.password || '' + }; + const response = await this.request(site, 'customers', 'POST', customerData); + return response.data; + } + + /** + * 更新ShopYY客户 + * @param site 站点配置 + * @param customerId 客户ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateCustomer(site: any, customerId: string | number, data: any): Promise { + // ShopYY API: PUT /customers/{id} + const customerData = { + firstname: data.first_name || '', + lastname: data.last_name || '', + email: data.email || '', + phone: data.phone || '' + }; + const response = await this.request(site, `customers/${customerId}`, 'PUT', customerData); + return response.data; + } + + /** + * 删除ShopYY客户 + * @param site 站点配置 + * @param customerId 客户ID + * @returns 删除结果 + */ + async deleteCustomer(site: any, customerId: string | number): Promise { + try { + // ShopYY API: DELETE /customers/{id} + await this.request(site, `customers/${customerId}`, 'DELETE'); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '删除ShopYY客户失败'; + throw new Error(`删除ShopYY客户失败: ${errorMessage}`); + } + } + + /** + * 创建ShopYY媒体 + * @param site 站点配置 + * @param file 文件 + * @returns 创建结果 + */ + async createMedia(site: any, file: any): Promise { + // ShopYY API: POST /media/create + const response = await this.requestWithFormData(site, 'media/create', { + file: fs.createReadStream(file.filepath), + }); + return response.data; + } + + /** + * 更新ShopYY媒体 + * @param site 站点配置 + * @param mediaId 媒体ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateMedia(site: any, mediaId: string | number, data: any): Promise { + // ShopYY API: POST /media/update/{id} + const response = await this.requestWithFormData(site, `media/update/${mediaId}`, data); + return response.data; + } + + /** + * 删除ShopYY媒体 + * @param site 站点配置 + * @param mediaId 媒体ID + * @returns 删除结果 + */ + async deleteMedia(site: any, mediaId: string | number): Promise { + try { + // ShopYY API: DELETE /media/delete/{id} + await this.request(site, `media/delete/${mediaId}`, 'DELETE'); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '删除ShopYY媒体失败'; + throw new Error(`删除ShopYY媒体失败: ${errorMessage}`); + } + } + + /** + * 使用multipart/form-data发送ShopYY API请求 + * @param site 站点配置 + * @param endpoint API端点 + * @param formData 表单数据 + * @returns 响应数据 + */ + private async requestWithFormData(site: any, endpoint: string, formData: Record): Promise { + const url = this.buildURL(site.apiUrl, endpoint); + const headers = this.buildHeaders(site); + const form = new FormData(); + for (const key in formData) { + form.append(key, formData[key]); + } + const config: AxiosRequestConfig = { + url, + method: 'POST', + headers: { + ...headers, + ...form.getHeaders(), + }, + data: form, + }; + + try { + const response = await axios(config); + return response.data; + } catch (error) { + console.error('ShopYY API (form-data)请求失败:', error.response?.data || error.message); + throw error; + } + } + + /** + * 构建产品链接 + * @param site 站点信息 + * @param product 产品信息 + * @param preview 是否是预览链接 + * @returns + */ + buildProductPermalink(site: Site, product: any, preview = false): string { + // 确保 product 和 product.handle 存在 + if (!product || !product.handle) { + // 如果 product 或 product.handle 不存在, 返回空字符串 + return ''; + } + + // 移除 websiteUrl 末尾的斜杠 (如果有) + const websiteUrl = site.websiteUrl.endsWith('/') ? site.websiteUrl.slice(0, -1) : site.websiteUrl; + + // 拼接 URL + let permalink = `${websiteUrl}/products/${product.handle}`; + + // 如果是预览, 添加预览参数 + if (preview) { + permalink += '?preview=1'; + } + + // 返回拼接后的产品 URL + return permalink; + } + + getApiClient(site: any): any { + throw new Error('Method not implemented.'); + } + + /** + * 获取ShopYY webhook列表 + * @param site 站点配置 + * @param params 查询参数 + * @returns 分页webhook列表 + */ + async getWebhooks(site: any, params: any): Promise { + // ShopYY API: GET /webhooks + const { items, total, totalPages, page, per_page } = + await this.fetchResourcePaged(site, 'webhooks', params); + return { + items, + total, + totalPages, + page, + per_page + }; + } + + /** + * 获取单个ShopYY webhook + * @param site 站点配置 + * @param webhookId webhook ID + * @returns webhook详情 + */ + async getWebhook(site: any, webhookId: string | number): Promise { + // ShopYY API: GET /webhooks/{id} + const response = await this.request(site, `webhooks/${webhookId}`, 'GET'); + return response.data; + } + + /** + * 创建ShopYY webhook + * @param site 站点配置 + * @param data webhook数据 + * @returns 创建结果 + */ + async createWebhook(site: any, data: any): Promise { + // ShopYY API: POST /webhooks + const response = await this.request(site, 'webhooks', 'POST', data); + return response.data; + } + + /** + * 更新ShopYY webhook + * @param site 站点配置 + * @param webhookId webhook ID + * @param data 更新数据 + * @returns 更新结果 + */ + async updateWebhook(site: any, webhookId: string | number, data: any): Promise { + // ShopYY API: PUT /webhooks/{id} + const response = await this.request(site, `webhooks/${webhookId}`, 'PUT', data); + return response.data; + } + + /** + * 删除ShopYY webhook + * @param site 站点配置 + * @param webhookId webhook ID + * @returns 删除结果 + */ + async deleteWebhook(site: any, webhookId: string | number): Promise { + try { + // ShopYY API: DELETE /webhooks/{id} + await this.request(site, `webhooks/${webhookId}`, 'DELETE'); + return true; + } catch (error) { + const errorMessage = error.response?.data?.msg || error.message || '删除ShopYY webhook失败'; + throw new Error(`删除ShopYY webhook失败: ${errorMessage}`); + } + } +} diff --git a/src/service/site-api.service.ts b/src/service/site-api.service.ts new file mode 100644 index 0000000..4c74849 --- /dev/null +++ b/src/service/site-api.service.ts @@ -0,0 +1,101 @@ +import { Inject, Provide } from '@midwayjs/core'; +import { ShopyyAdapter } from '../adapter/shopyy.adapter'; +import { WooCommerceAdapter } from '../adapter/woocommerce.adapter'; +import { ISiteAdapter } from '../interface/site-adapter.interface'; +import { ShopyyService } from './shopyy.service'; +import { SiteService } from './site.service'; +import { WPService } from './wp.service'; +import { ProductService } from './product.service'; + +@Provide() +export class SiteApiService { + @Inject() + siteService: SiteService; + + @Inject() + wpService: WPService; + + @Inject() + shopyyService: ShopyyService; + + @Inject() + productService: ProductService; + + async getAdapter(siteId: number): Promise { + const site = await this.siteService.get(siteId, true); + if (!site) { + throw new Error(`Site ${siteId} not found`); + } + + if (site.type === 'woocommerce') { + if (!site?.consumerKey || !site.consumerSecret || !site.apiUrl) { + throw new Error('站点配置缺少 consumerKey/consumerSecret/apiUrl'); + } + return new WooCommerceAdapter(site, this.wpService); + } else if (site.type === 'shopyy') { + if (!site?.token) { + throw new Error('站点配置缺少 token'); + } + return new ShopyyAdapter(site, this.shopyyService); + } + + throw new Error(`Unsupported site type: ${site.type}`); + } + + /** + * 获取站点商品并关联ERP产品信息 + * @param siteId 站点ID + * @param siteProduct 站点商品信息 + * @returns 包含ERP产品信息的站点商品 + */ + async enrichSiteProductWithErpInfo(siteId: number, siteProduct: any): Promise { + if (!siteProduct || !siteProduct.sku) { + return siteProduct; + } + + try { + // 使用站点SKU查询对应的ERP产品 + const erpProduct = await this.productService.findProductBySiteSku(siteProduct.sku); + + // 将ERP产品信息合并到站点商品中 + return { + ...siteProduct, + erpProduct: { + id: erpProduct.id, + sku: erpProduct.sku, + name: erpProduct.name, + nameCn: erpProduct.nameCn, + category: erpProduct.category, + attributes: erpProduct.attributes, + components: erpProduct.components, + price: erpProduct.price, + promotionPrice: erpProduct.promotionPrice, + // 可以根据需要添加更多ERP产品字段 + } + }; + } catch (error) { + // 如果找不到对应的ERP产品,返回原始站点商品 + console.warn(`未找到站点SKU ${siteProduct.sku} 对应的ERP产品: ${error.message}`); + return siteProduct; + } + } + + /** + * 批量获取站点商品并关联ERP产品信息 + * @param siteId 站点ID + * @param siteProducts 站点商品列表 + * @returns 包含ERP产品信息的站点商品列表 + */ + async enrichSiteProductsWithErpInfo(siteId: number, siteProducts: any[]): Promise { + if (!siteProducts || !siteProducts.length) { + return siteProducts; + } + + // 并行处理所有商品 + const enrichedProducts = await Promise.all( + siteProducts.map(product => this.enrichSiteProductWithErpInfo(siteId, product)) + ); + + return enrichedProducts; + } +} diff --git a/src/service/site.service.ts b/src/service/site.service.ts index a53efa5..4585bf0 100644 --- a/src/service/site.service.ts +++ b/src/service/site.service.ts @@ -3,7 +3,9 @@ import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository, Like, In } from 'typeorm'; import { Site } from '../entity/site.entity'; import { WpSite } from '../interface'; -import { UpdateSiteDTO } from '../dto/site.dto'; +import { CreateSiteDTO, UpdateSiteDTO } from '../dto/site.dto'; +import { Area } from '../entity/area.entity'; +import { StockPoint } from '../entity/stock_point.entity'; @Provide() @Scope(ScopeEnum.Singleton) @@ -11,86 +13,189 @@ export class SiteService { @InjectEntityModel(Site) siteModel: Repository; + @InjectEntityModel(Area) + areaModel: Repository; + + @InjectEntityModel(StockPoint) + stockPointModel: Repository; + async syncFromConfig(sites: WpSite[] = []) { - // 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化) + // 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化) for (const siteConfig of sites) { // 按站点名称查询是否已存在记录 - const exist = await this.siteModel.findOne({ where: { siteName: siteConfig.siteName } }); + const exist = await this.siteModel.findOne({ + where: { name: siteConfig.name }, + }); // 将 WpSite 字段映射为 Site 实体字段 const payload: Partial = { - siteName: siteConfig.siteName, + name: siteConfig.name, apiUrl: (siteConfig as any).wpApiUrl, consumerKey: (siteConfig as any).consumerKey, consumerSecret: (siteConfig as any).consumerSecret, type: 'woocommerce', }; - // 存在则更新,不存在则插入新记录 - if (exist) await this.siteModel.update({ id: exist.id }, payload); - else await this.siteModel.insert(payload as Site); + // 存在则更新,不存在则插入新记录 + if (exist) { + await this.siteModel.update({ id: exist.id }, payload); + } else { + await this.siteModel.insert(payload as Site); + } } } - async create(data: Partial) { - // 创建新的站点记录 - await this.siteModel.insert(data as Site); + async create(data: CreateSiteDTO) { + // 从 DTO 中分离出区域代码和其他站点数据 + const { areas: areaCodes, stockPointIds, websiteUrl, ...restData } = data; + const newSite = new Site(); + Object.assign(newSite, restData, { websiteUrl }); + + // 如果传入了区域代码,则查询并关联 Area 实体 + if (areaCodes && areaCodes.length > 0) { + const areas = await this.areaModel.findBy({ + code: In(areaCodes), + }); + newSite.areas = areas; + } else { + // 如果没有传入区域,则关联一个空数组,代表"全局" + newSite.areas = []; + } + + // 如果传入了仓库点 ID,则查询并关联 StockPoint 实体 + if (stockPointIds && stockPointIds.length > 0) { + const stockPoints = await this.stockPointModel.findBy({ + id: In(stockPointIds.map(Number)), + }); + newSite.stockPoints = stockPoints; + } else { + newSite.stockPoints = []; + } + + // 使用 save 方法保存实体及其关联关系 + await this.siteModel.save(newSite); return true; } async update(id: string | number, data: UpdateSiteDTO) { - // 更新指定站点记录,将布尔 isDisabled 转换为数值 0/1 - const payload: Partial = { - ...data, - isDisabled: - data.isDisabled === undefined // 未传入则不更新该字段 - ? undefined - : data.isDisabled // true -> 1, false -> 0 - ? 1 - : 0, - } as any; - await this.siteModel.update({ id: Number(id) }, payload); + const { areas: areaCodes, stockPointIds, isDisabled, ...restData } = data; + + // 首先,根据 ID 查找要更新的站点实体 + const siteToUpdate = await this.siteModel.findOne({ + where: { id: Number(id) }, + relations: ['areas', 'stockPoints'], + }); + if (!siteToUpdate) { + // 如果找不到站点,则操作失败 + return false; + } + + // 更新站点的基本字段 + Object.assign(siteToUpdate, restData); + if (isDisabled !== undefined) { + siteToUpdate.isDisabled = isDisabled; + } + + // 如果 DTO 中传入了 areas 字段(即使是空数组),也要更新关联关系 + if (areaCodes !== undefined) { + if (areaCodes.length > 0) { + // 如果区域代码数组不为空,则查找并更新关联 + const areas = await this.areaModel.findBy({ + code: In(areaCodes), + }); + siteToUpdate.areas = areas; + } else { + // 如果传入空数组,则清空所有关联,代表"全局" + siteToUpdate.areas = []; + } + } + + // 如果 DTO 中传入了 stockPointIds 字段(即使是空数组),也要更新关联关系 + if (stockPointIds !== undefined) { + if (stockPointIds.length > 0) { + const stockPoints = await this.stockPointModel.findBy({ + id: In(stockPointIds.map(Number)), + }); + siteToUpdate.stockPoints = stockPoints; + } else { + siteToUpdate.stockPoints = []; + } + } + + // 使用 save 方法保存实体及其更新后的关联关系 + await this.siteModel.save(siteToUpdate); return true; } - async get(id: string | number, includeSecret = false) { - // 根据主键获取站点;includeSecret 为 true 时返回密钥字段 - const site = await this.siteModel.findOne({ where: { id: Number(id) } }); - if (!site) return null; - if (includeSecret) return site; - // 默认不返回密钥,进行字段脱敏 - const { ...rest } = site; + async get(id: string | number, includeSecret = false):Promise { + // 根据主键获取站点,并使用 relations 加载关联的 areas + const site = await this.siteModel.findOne({ + where: { id: Number(id) }, + relations: ['areas', 'stockPoints'], + }); + if (!site) { + return null; + } + // 如果需要包含密钥,则直接返回 + if (includeSecret) { + return site; + } + // 默认不返回密钥,进行字段脱敏 + const { consumerKey, consumerSecret, ...rest } = site; return rest; } - - async list(param: { current?: number; pageSize?: number; keyword?: string; isDisabled?: boolean; ids?: string }, includeSecret = false) { - // 分页查询站点列表,支持关键字、禁用状态与 ID 列表过滤 - const { current = 1, pageSize = 10, keyword, isDisabled, ids } = (param || {}) as any; + + async list( + param: { + current?: number; + pageSize?: number; + keyword?: string; + isDisabled?: boolean; + ids?: string; + }, + includeSecret = false + ) { + // 分页查询站点列表,支持关键字,禁用状态与 ID 列表过滤 + const { current = 1, pageSize = 10, keyword, isDisabled, ids } = (param || + {}) as any; const where: any = {}; // 按名称模糊查询 - if (keyword) where.siteName = Like(`%${keyword}%`); - // 按禁用状态过滤(布尔转数值) - if (typeof isDisabled === 'boolean') where.isDisabled = isDisabled ? 1 : 0; + if (keyword) { + where.name = Like(`%${keyword}%`); + } + // 按禁用状态过滤(布尔转数值) + if (typeof isDisabled === 'boolean') { + where.isDisabled = isDisabled ? 1 : 0; + } if (ids) { - // 解析逗号分隔的 ID 字符串为数字数组,并过滤非法值 + // 解析逗号分隔的 ID 字符串为数字数组,并过滤非法值 const numIds = String(ids) .split(',') .filter(Boolean) - .map((i) => Number(i)) - .filter((v) => !Number.isNaN(v)); - if (numIds.length > 0) where.id = In(numIds); + .map(i => Number(i)) + .filter(v => !Number.isNaN(v)); + if (numIds.length > 0) { + where.id = In(numIds); + } } - // 进行分页查询(skip/take)并返回总条数 - const [items, total] = await this.siteModel.findAndCount({ where, skip: (current - 1) * pageSize, take: pageSize }); - // 根据 includeSecret 决定是否脱敏返回密钥字段 - const data = includeSecret ? items : items.map((item: any) => { - const { consumerKey, consumerSecret, ...rest } = item; - return rest; + // 进行分页查询,并使用 relations 加载关联的 areas + const [items, total] = await this.siteModel.findAndCount({ + where, + skip: (current - 1) * pageSize, + take: pageSize, + relations: ['areas', 'stockPoints'], }); + // 根据 includeSecret 决定是否脱敏返回密钥字段 + const data = includeSecret + ? items + : items.map((item: any) => { + const { consumerKey, consumerSecret, ...rest } = item; + return rest; + }); return { items: data, total, current, pageSize }; } async disable(id: string | number, disabled: boolean) { - // 设置站点禁用状态(true -> 1, false -> 0) - await this.siteModel.update({ id: Number(id) }, { isDisabled: disabled ? 1 : 0 }); + // 设置站点禁用状态(true -> 1, false -> 0) + await this.siteModel.update({ id: Number(id) }, { isDisabled: disabled }); return true; } } \ No newline at end of file diff --git a/src/service/statistics.service.ts b/src/service/statistics.service.ts index 949c040..71804ce 100644 --- a/src/service/statistics.service.ts +++ b/src/service/statistics.service.ts @@ -15,14 +15,12 @@ export class StatisticsService { orderItemRepository: Repository; async getOrderStatistics(params: OrderStatisticsParams) { - const { startDate, endDate, siteId ,grouping} = params; + const { startDate, endDate, siteId } = params; // const keywords = keyword ? keyword.split(' ').filter(Boolean) : []; const start = dayjs(startDate).format('YYYY-MM-DD'); const end = dayjs(endDate).add(1, 'd').format('YYYY-MM-DD'); - - let sql = ''; - if (!grouping || grouping === 'day') { - sql = `WITH first_order AS ( + let sql = ` + WITH first_order AS ( SELECT customer_email, MIN(date_paid) AS first_purchase_date FROM \`order\` GROUP BY customer_email @@ -216,393 +214,6 @@ export class StatisticsService { dt.can_total_orders ORDER BY d.order_date DESC; `; - }else if (grouping === 'week') { - sql = `WITH first_order AS ( - SELECT customer_email, MIN(date_paid) AS first_purchase_date - FROM \`order\` - GROUP BY customer_email - ), - weekly_orders AS ( - SELECT - o.id AS order_id, - DATE_FORMAT(o.date_paid, '%Y-%u') AS order_date, - o.customer_email, - o.total, - o.source_type, - o.utm_source, - o.siteId, - CASE - WHEN o.date_paid = f.first_purchase_date THEN 'first_purchase' - ELSE 'repeat_purchase' - END AS purchase_type, - CASE - WHEN o.source_type = 'utm' AND o.utm_source = 'google' THEN 'cpc' - ELSE 'non_cpc' - END AS order_type, - MAX(CASE WHEN oi.name LIKE '%zyn%' THEN 'zyn' ELSE 'non_zyn' END) AS zyn_type, - MAX(CASE WHEN oi.name LIKE '%yoone%' THEN 'yoone' ELSE 'non_yoone' END) AS yoone_type, - MAX(CASE WHEN oi.name LIKE '%zex%' THEN 'zex' ELSE 'non_zex' END) AS zex_type - FROM \`order\` o - LEFT JOIN first_order f ON o.customer_email = f.customer_email - LEFT JOIN order_item oi ON o.id = oi.orderId - WHERE o.date_paid IS NOT NULL - AND o.date_paid >= '${start}' AND o.date_paid < '${end}' - AND o.status IN ('processing','completed') - GROUP BY o.id, o.date_paid, o.customer_email, o.total, o.source_type, o.siteId, o.utm_source - ), - order_sales_summary AS ( - SELECT - orderId, - SUM(CASE WHEN name LIKE '%zyn%' THEN quantity ELSE 0 END) AS zyn_quantity, - SUM(CASE WHEN name LIKE '%yoone%' THEN quantity ELSE 0 END) AS yoone_quantity, - SUM(CASE WHEN name LIKE '%zex%' THEN quantity ELSE 0 END) AS zex_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND isPackage = 1 THEN quantity ELSE 0 END) AS yoone_G_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND isPackage = 0 THEN quantity ELSE 0 END) AS yoone_S_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%3%' THEN quantity ELSE 0 END) AS yoone_3_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%6%' THEN quantity ELSE 0 END) AS yoone_6_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%9%' THEN quantity ELSE 0 END) AS yoone_9_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%12%' THEN quantity ELSE 0 END) AS yoone_12_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%15%' THEN quantity ELSE 0 END) AS yoone_15_quantity - FROM order_sale - GROUP BY orderId - ), - order_items_summary AS ( - SELECT - orderId, - SUM(CASE WHEN name LIKE '%zyn%' THEN total + total_tax ELSE 0 END) AS zyn_amount, - SUM(CASE WHEN name LIKE '%yoone%' THEN total + total_tax ELSE 0 END) AS yoone_amount, - SUM(CASE WHEN name LIKE '%zex%' THEN total + total_tax ELSE 0 END) AS zex_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%package%' THEN total + total_tax ELSE 0 END) AS yoone_G_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name NOT LIKE '%package%' THEN total + total_tax ELSE 0 END) AS yoone_S_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%3%' THEN total + total_tax ELSE 0 END) AS yoone_3_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%6%' THEN total + total_tax ELSE 0 END) AS yoone_6_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%9%' THEN total + total_tax ELSE 0 END) AS yoone_9_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%12%' THEN total + total_tax ELSE 0 END) AS yoone_12_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%15%' THEN total + total_tax ELSE 0 END) AS yoone_15_amount - FROM order_item - GROUP BY orderId - ), - weekly_totals AS ( - SELECT order_date, SUM(total) AS total_amount, - SUM(CASE WHEN siteId = 1 THEN total ELSE 0 END) AS togo_total_amount, - SUM(CASE WHEN siteId = 2 THEN total ELSE 0 END) AS can_total_amount, - COUNT(DISTINCT order_id) AS total_orders, - COUNT(DISTINCT CASE WHEN siteId = 1 THEN order_id END) AS togo_total_orders, - COUNT(DISTINCT CASE WHEN siteId = 2 THEN order_id END) AS can_total_orders, - SUM(CASE WHEN purchase_type = 'first_purchase' THEN total ELSE 0 END) AS first_purchase_total, - SUM(CASE WHEN purchase_type = 'repeat_purchase' THEN total ELSE 0 END) AS repeat_purchase_total, - SUM(CASE WHEN order_type = 'cpc' THEN total ELSE 0 END) AS cpc_total, - SUM(CASE WHEN order_type = 'non_cpc' THEN total ELSE 0 END) AS non_cpc_total, - SUM(CASE WHEN zyn_type = 'zyn' AND order_type = 'cpc' THEN total ELSE 0 END) AS zyn_total, - SUM(CASE WHEN zyn_type = 'zyn' AND order_type = 'non_cpc' THEN total ELSE 0 END) AS non_zyn_total, - SUM(CASE WHEN yoone_type = 'yoone' AND order_type = 'cpc' THEN total ELSE 0 END) AS yoone_total, - SUM(CASE WHEN yoone_type = 'yoone' AND order_type = 'non_cpc' THEN total ELSE 0 END) AS non_yoone_total, - SUM(CASE WHEN zex_type = 'zex' AND order_type = 'cpc' THEN total ELSE 0 END) AS zex_total, - SUM(CASE WHEN zex_type = 'zex' AND order_type = 'non_cpc' THEN total ELSE 0 END) AS non_zex_total, - SUM(CASE WHEN source_type = 'typein' THEN total ELSE 0 END) AS direct_total, - SUM(CASE WHEN source_type = 'organic' THEN total ELSE 0 END) AS organic_total - FROM weekly_orders - GROUP BY order_date - ) - SELECT - wt.order_date, - COUNT(DISTINCT CASE WHEN wo.purchase_type = 'first_purchase' THEN wo.order_id END) AS first_purchase_orders, - COUNT(DISTINCT CASE WHEN wo.purchase_type = 'repeat_purchase' THEN wo.order_id END) AS repeat_purchase_orders, - COUNT(DISTINCT CASE WHEN wo.order_type = 'cpc' THEN wo.order_id END) AS cpc_orders, - COUNT(DISTINCT CASE WHEN wo.order_type = 'cpc' AND wo.siteId = 1 THEN wo.order_id END) AS togo_cpc_orders, - COUNT(DISTINCT CASE WHEN wo.order_type = 'cpc' AND wo.siteId = 2 THEN wo.order_id END) AS can_cpc_orders, - COUNT(DISTINCT CASE WHEN wo.order_type = 'non_cpc' THEN wo.order_id END) AS non_cpc_orders, - COUNT(DISTINCT CASE WHEN wo.order_type = 'non_cpc' AND wo.siteId = 1 THEN wo.order_id END) AS non_togo_cpc_orders, - COUNT(DISTINCT CASE WHEN wo.order_type = 'non_cpc' AND wo.siteId = 2 THEN wo.order_id END) AS non_can_cpc_orders, - COUNT(DISTINCT CASE WHEN wo.zyn_type = 'zyn' AND wo.order_type = 'cpc' THEN wo.order_id END) AS zyn_orders, - COUNT(DISTINCT CASE WHEN wo.zyn_type = 'zyn' AND wo.order_type = 'non_cpc' THEN wo.order_id END) AS non_zyn_orders, - COUNT(DISTINCT CASE WHEN wo.yoone_type = 'yoone' AND wo.order_type = 'cpc' THEN wo.order_id END) AS yoone_orders, - COUNT(DISTINCT CASE WHEN wo.yoone_type = 'yoone' AND wo.order_type = 'non_cpc' THEN wo.order_id END) AS non_yoone_orders, - COUNT(DISTINCT CASE WHEN wo.zex_type = 'zex' AND wo.order_type = 'cpc' THEN wo.order_id END) AS zex_orders, - COUNT(DISTINCT CASE WHEN wo.zex_type = 'zex' AND wo.order_type = 'non_cpc' THEN wo.order_id END) AS non_zex_orders, - COUNT(DISTINCT CASE WHEN wo.source_type = 'typein' THEN wo.order_id END) AS direct_orders, - COUNT(DISTINCT CASE WHEN wo.source_type = 'organic' THEN wo.order_id END) AS organic_orders, - wt.total_orders, - wt.togo_total_orders, - wt.can_total_orders, - wt.total_amount, - wt.togo_total_amount, - wt.can_total_amount, - wt.first_purchase_total, - wt.repeat_purchase_total, - wt.cpc_total, - wt.non_cpc_total, - wt.zyn_total, - wt.non_zyn_total, - wt.yoone_total, - wt.non_yoone_total, - wt.zex_total, - wt.non_zex_total, - wt.direct_total, - wt.organic_total, - COALESCE(SUM(os.zyn_quantity), 0) AS zyn_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.zyn_quantity ELSE 0 END) AS cpc_zyn_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.zyn_quantity ELSE 0 END) AS non_cpc_zyn_quantity, - COALESCE(SUM(os.yoone_quantity), 0) AS yoone_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.yoone_quantity ELSE 0 END) AS cpc_yoone_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.yoone_quantity ELSE 0 END) AS non_cpc_yoone_quantity, - COALESCE(SUM(os.yoone_G_quantity), 0) AS yoone_G_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.yoone_G_quantity ELSE 0 END) AS cpc_yoone_G_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.yoone_G_quantity ELSE 0 END) AS non_cpc_yoone_G_quantity, - COALESCE(SUM(os.yoone_S_quantity), 0) AS yoone_S_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.yoone_S_quantity ELSE 0 END) AS cpc_yoone_S_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.yoone_S_quantity ELSE 0 END) AS non_cpc_yoone_S_quantity, - COALESCE(SUM(os.yoone_3_quantity), 0) AS yoone_3_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.yoone_3_quantity ELSE 0 END) AS cpc_yoone_3_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.yoone_3_quantity ELSE 0 END) AS non_cpc_yoone_3_quantity, - COALESCE(SUM(os.yoone_6_quantity), 0) AS yoone_6_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.yoone_6_quantity ELSE 0 END) AS cpc_yoone_6_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.yoone_6_quantity ELSE 0 END) AS non_cpc_yoone_6_quantity, - COALESCE(SUM(os.yoone_9_quantity), 0) AS yoone_9_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.yoone_9_quantity ELSE 0 END) AS cpc_yoone_9_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.yoone_9_quantity ELSE 0 END) AS non_cpc_yoone_9_quantity, - COALESCE(SUM(os.yoone_12_quantity), 0) AS yoone_12_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.yoone_12_quantity ELSE 0 END) AS cpc_yoone_12_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.yoone_12_quantity ELSE 0 END) AS non_cpc_yoone_12_quantity, - COALESCE(SUM(os.yoone_15_quantity), 0) AS yoone_15_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.yoone_15_quantity ELSE 0 END) AS cpc_yoone_15_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.yoone_15_quantity ELSE 0 END) AS non_cpc_yoone_15_quantity, - COALESCE(SUM(os.zex_quantity), 0) AS zex_quantity, - SUM(CASE WHEN wo.order_type = 'cpc' THEN os.zex_quantity ELSE 0 END) AS cpc_zex_quantity, - SUM(CASE WHEN wo.order_type = 'non_cpc' THEN os.zex_quantity ELSE 0 END) AS non_cpc_zex_quantity, - COALESCE(SUM(oi.zyn_amount), 0) AS zyn_amount, - COALESCE(SUM(oi.yoone_amount), 0) AS yoone_amount, - COALESCE(SUM(oi.zex_amount), 0) AS zex_amount, - COALESCE(SUM(oi.yoone_G_amount), 0) AS yoone_G_amount, - COALESCE(SUM(oi.yoone_S_amount), 0) AS yoone_S_amount, - COALESCE(SUM(oi.yoone_3_amount), 0) AS yoone_3_amount, - COALESCE(SUM(oi.yoone_6_amount), 0) AS yoone_6_amount, - COALESCE(SUM(oi.yoone_9_amount), 0) AS yoone_9_amount, - COALESCE(SUM(oi.yoone_12_amount), 0) AS yoone_12_amount, - COALESCE(SUM(oi.yoone_15_amount), 0) AS yoone_15_amount, - ROUND(COALESCE(wt.total_amount / wt.total_orders, 0), 2) AS avg_total_amount, - ROUND(COALESCE(wt.togo_total_amount / wt.togo_total_orders, 0), 2) AS avg_togo_total_amount, - ROUND(COALESCE(wt.can_total_amount / wt.can_total_orders, 0), 2) AS avg_can_total_amount - FROM weekly_orders wo - LEFT JOIN weekly_totals wt ON wo.order_date = wt.order_date - LEFT JOIN order_sales_summary os ON wo.order_id = os.orderId - LEFT JOIN order_items_summary oi ON wo.order_id = oi.orderId - GROUP BY - wt.order_date, - wt.total_amount, - wt.togo_total_amount, - wt.can_total_amount, - wt.first_purchase_total, - wt.repeat_purchase_total, - wt.cpc_total, - wt.non_cpc_total, - wt.zyn_total, - wt.non_zyn_total, - wt.yoone_total, - wt.non_yoone_total, - wt.zex_total, - wt.non_zex_total, - wt.direct_total, - wt.organic_total, - wt.total_orders, - wt.togo_total_orders, - wt.can_total_orders - ORDER BY wt.order_date DESC; - `; - }else if (grouping === 'month') { - sql = `WITH first_order AS ( - SELECT customer_email, MIN(date_paid) AS first_purchase_date - FROM \`order\` - GROUP BY customer_email - ), - monthly_orders AS ( - SELECT - o.id AS order_id, - DATE_FORMAT(o.date_paid, '%Y-%m') AS order_date, - o.customer_email, - o.total, - o.source_type, - o.utm_source, - o.siteId, - CASE - WHEN o.date_paid = f.first_purchase_date THEN 'first_purchase' - ELSE 'repeat_purchase' - END AS purchase_type, - CASE - WHEN o.source_type = 'utm' AND o.utm_source = 'google' THEN 'cpc' - ELSE 'non_cpc' - END AS order_type, - MAX(CASE WHEN oi.name LIKE '%zyn%' THEN 'zyn' ELSE 'non_zyn' END) AS zyn_type, - MAX(CASE WHEN oi.name LIKE '%yoone%' THEN 'yoone' ELSE 'non_yoone' END) AS yoone_type, - MAX(CASE WHEN oi.name LIKE '%zex%' THEN 'zex' ELSE 'non_zex' END) AS zex_type - FROM \`order\` o - LEFT JOIN first_order f ON o.customer_email = f.customer_email - LEFT JOIN order_item oi ON o.id = oi.orderId - WHERE o.date_paid IS NOT NULL - AND o.date_paid >= '${start}' AND o.date_paid < '${end}' - AND o.status IN ('processing','completed') - GROUP BY o.id, o.date_paid, o.customer_email, o.total, o.source_type, o.siteId, o.utm_source - ), - order_sales_summary AS ( - SELECT - orderId, - SUM(CASE WHEN name LIKE '%zyn%' THEN quantity ELSE 0 END) AS zyn_quantity, - SUM(CASE WHEN name LIKE '%yoone%' THEN quantity ELSE 0 END) AS yoone_quantity, - SUM(CASE WHEN name LIKE '%zex%' THEN quantity ELSE 0 END) AS zex_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND isPackage = 1 THEN quantity ELSE 0 END) AS yoone_G_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND isPackage = 0 THEN quantity ELSE 0 END) AS yoone_S_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%3%' THEN quantity ELSE 0 END) AS yoone_3_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%6%' THEN quantity ELSE 0 END) AS yoone_6_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%9%' THEN quantity ELSE 0 END) AS yoone_9_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%12%' THEN quantity ELSE 0 END) AS yoone_12_quantity, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%15%' THEN quantity ELSE 0 END) AS yoone_15_quantity - FROM order_sale - GROUP BY orderId - ), - order_items_summary AS ( - SELECT - orderId, - SUM(CASE WHEN name LIKE '%zyn%' THEN total + total_tax ELSE 0 END) AS zyn_amount, - SUM(CASE WHEN name LIKE '%yoone%' THEN total + total_tax ELSE 0 END) AS yoone_amount, - SUM(CASE WHEN name LIKE '%zex%' THEN total + total_tax ELSE 0 END) AS zex_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%package%' THEN total + total_tax ELSE 0 END) AS yoone_G_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name NOT LIKE '%package%' THEN total + total_tax ELSE 0 END) AS yoone_S_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%3%' THEN total + total_tax ELSE 0 END) AS yoone_3_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%6%' THEN total + total_tax ELSE 0 END) AS yoone_6_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%9%' THEN total + total_tax ELSE 0 END) AS yoone_9_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%12%' THEN total + total_tax ELSE 0 END) AS yoone_12_amount, - SUM(CASE WHEN name LIKE '%yoone%' AND name LIKE '%15%' THEN total + total_tax ELSE 0 END) AS yoone_15_amount - FROM order_item - GROUP BY orderId - ), - monthly_totals AS ( - SELECT order_date, SUM(total) AS total_amount, - SUM(CASE WHEN siteId = 1 THEN total ELSE 0 END) AS togo_total_amount, - SUM(CASE WHEN siteId = 2 THEN total ELSE 0 END) AS can_total_amount, - COUNT(DISTINCT order_id) AS total_orders, - COUNT(DISTINCT CASE WHEN siteId = 1 THEN order_id END) AS togo_total_orders, - COUNT(DISTINCT CASE WHEN siteId = 2 THEN order_id END) AS can_total_orders, - SUM(CASE WHEN purchase_type = 'first_purchase' THEN total ELSE 0 END) AS first_purchase_total, - SUM(CASE WHEN purchase_type = 'repeat_purchase' THEN total ELSE 0 END) AS repeat_purchase_total, - SUM(CASE WHEN order_type = 'cpc' THEN total ELSE 0 END) AS cpc_total, - SUM(CASE WHEN order_type = 'non_cpc' THEN total ELSE 0 END) AS non_cpc_total, - SUM(CASE WHEN zyn_type = 'zyn' AND order_type = 'cpc' THEN total ELSE 0 END) AS zyn_total, - SUM(CASE WHEN zyn_type = 'zyn' AND order_type = 'non_cpc' THEN total ELSE 0 END) AS non_zyn_total, - SUM(CASE WHEN yoone_type = 'yoone' AND order_type = 'cpc' THEN total ELSE 0 END) AS yoone_total, - SUM(CASE WHEN yoone_type = 'yoone' AND order_type = 'non_cpc' THEN total ELSE 0 END) AS non_yoone_total, - SUM(CASE WHEN zex_type = 'zex' AND order_type = 'cpc' THEN total ELSE 0 END) AS zex_total, - SUM(CASE WHEN zex_type = 'zex' AND order_type = 'non_cpc' THEN total ELSE 0 END) AS non_zex_total, - SUM(CASE WHEN source_type = 'typein' THEN total ELSE 0 END) AS direct_total, - SUM(CASE WHEN source_type = 'organic' THEN total ELSE 0 END) AS organic_total - FROM monthly_orders - GROUP BY order_date - ) - SELECT - mt.order_date, - COUNT(DISTINCT CASE WHEN mo.purchase_type = 'first_purchase' THEN mo.order_id END) AS first_purchase_orders, - COUNT(DISTINCT CASE WHEN mo.purchase_type = 'repeat_purchase' THEN mo.order_id END) AS repeat_purchase_orders, - COUNT(DISTINCT CASE WHEN mo.order_type = 'cpc' THEN mo.order_id END) AS cpc_orders, - COUNT(DISTINCT CASE WHEN mo.order_type = 'cpc' AND mo.siteId = 1 THEN mo.order_id END) AS togo_cpc_orders, - COUNT(DISTINCT CASE WHEN mo.order_type = 'cpc' AND mo.siteId = 2 THEN mo.order_id END) AS can_cpc_orders, - COUNT(DISTINCT CASE WHEN mo.order_type = 'non_cpc' THEN mo.order_id END) AS non_cpc_orders, - COUNT(DISTINCT CASE WHEN mo.order_type = 'non_cpc' AND mo.siteId = 1 THEN mo.order_id END) AS non_togo_cpc_orders, - COUNT(DISTINCT CASE WHEN mo.order_type = 'non_cpc' AND mo.siteId = 2 THEN mo.order_id END) AS non_can_cpc_orders, - COUNT(DISTINCT CASE WHEN mo.zyn_type = 'zyn' AND mo.order_type = 'cpc' THEN mo.order_id END) AS zyn_orders, - COUNT(DISTINCT CASE WHEN mo.zyn_type = 'zyn' AND mo.order_type = 'non_cpc' THEN mo.order_id END) AS non_zyn_orders, - COUNT(DISTINCT CASE WHEN mo.yoone_type = 'yoone' AND mo.order_type = 'cpc' THEN mo.order_id END) AS yoone_orders, - COUNT(DISTINCT CASE WHEN mo.yoone_type = 'yoone' AND mo.order_type = 'non_cpc' THEN mo.order_id END) AS non_yoone_orders, - COUNT(DISTINCT CASE WHEN mo.zex_type = 'zex' AND mo.order_type = 'cpc' THEN mo.order_id END) AS zex_orders, - COUNT(DISTINCT CASE WHEN mo.zex_type = 'zex' AND mo.order_type = 'non_cpc' THEN mo.order_id END) AS non_zex_orders, - COUNT(DISTINCT CASE WHEN mo.source_type = 'typein' THEN mo.order_id END) AS direct_orders, - COUNT(DISTINCT CASE WHEN mo.source_type = 'organic' THEN mo.order_id END) AS organic_orders, - mt.total_orders, - mt.togo_total_orders, - mt.can_total_orders, - mt.total_amount, - mt.togo_total_amount, - mt.can_total_amount, - mt.first_purchase_total, - mt.repeat_purchase_total, - mt.cpc_total, - mt.non_cpc_total, - mt.zyn_total, - mt.non_zyn_total, - mt.yoone_total, - mt.non_yoone_total, - mt.zex_total, - mt.non_zex_total, - mt.direct_total, - mt.organic_total, - COALESCE(SUM(os.zyn_quantity), 0) AS zyn_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.zyn_quantity ELSE 0 END) AS cpc_zyn_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.zyn_quantity ELSE 0 END) AS non_cpc_zyn_quantity, - COALESCE(SUM(os.yoone_quantity), 0) AS yoone_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.yoone_quantity ELSE 0 END) AS cpc_yoone_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.yoone_quantity ELSE 0 END) AS non_cpc_yoone_quantity, - COALESCE(SUM(os.yoone_G_quantity), 0) AS yoone_G_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.yoone_G_quantity ELSE 0 END) AS cpc_yoone_G_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.yoone_G_quantity ELSE 0 END) AS non_cpc_yoone_G_quantity, - COALESCE(SUM(os.yoone_S_quantity), 0) AS yoone_S_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.yoone_S_quantity ELSE 0 END) AS cpc_yoone_S_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.yoone_S_quantity ELSE 0 END) AS non_cpc_yoone_S_quantity, - COALESCE(SUM(os.yoone_3_quantity), 0) AS yoone_3_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.yoone_3_quantity ELSE 0 END) AS cpc_yoone_3_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.yoone_3_quantity ELSE 0 END) AS non_cpc_yoone_3_quantity, - COALESCE(SUM(os.yoone_6_quantity), 0) AS yoone_6_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.yoone_6_quantity ELSE 0 END) AS cpc_yoone_6_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.yoone_6_quantity ELSE 0 END) AS non_cpc_yoone_6_quantity, - COALESCE(SUM(os.yoone_9_quantity), 0) AS yoone_9_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.yoone_9_quantity ELSE 0 END) AS cpc_yoone_9_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.yoone_9_quantity ELSE 0 END) AS non_cpc_yoone_9_quantity, - COALESCE(SUM(os.yoone_12_quantity), 0) AS yoone_12_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.yoone_12_quantity ELSE 0 END) AS cpc_yoone_12_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.yoone_12_quantity ELSE 0 END) AS non_cpc_yoone_12_quantity, - COALESCE(SUM(os.yoone_15_quantity), 0) AS yoone_15_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.yoone_15_quantity ELSE 0 END) AS cpc_yoone_15_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.yoone_15_quantity ELSE 0 END) AS non_cpc_yoone_15_quantity, - COALESCE(SUM(os.zex_quantity), 0) AS zex_quantity, - SUM(CASE WHEN mo.order_type = 'cpc' THEN os.zex_quantity ELSE 0 END) AS cpc_zex_quantity, - SUM(CASE WHEN mo.order_type = 'non_cpc' THEN os.zex_quantity ELSE 0 END) AS non_cpc_zex_quantity, - COALESCE(SUM(oi.zyn_amount), 0) AS zyn_amount, - COALESCE(SUM(oi.yoone_amount), 0) AS yoone_amount, - COALESCE(SUM(oi.zex_amount), 0) AS zex_amount, - COALESCE(SUM(oi.yoone_G_amount), 0) AS yoone_G_amount, - COALESCE(SUM(oi.yoone_S_amount), 0) AS yoone_S_amount, - COALESCE(SUM(oi.yoone_3_amount), 0) AS yoone_3_amount, - COALESCE(SUM(oi.yoone_6_amount), 0) AS yoone_6_amount, - COALESCE(SUM(oi.yoone_9_amount), 0) AS yoone_9_amount, - COALESCE(SUM(oi.yoone_12_amount), 0) AS yoone_12_amount, - COALESCE(SUM(oi.yoone_15_amount), 0) AS yoone_15_amount, - ROUND(COALESCE(mt.total_amount / mt.total_orders, 0), 2) AS avg_total_amount, - ROUND(COALESCE(mt.togo_total_amount / mt.togo_total_orders, 0), 2) AS avg_togo_total_amount, - ROUND(COALESCE(mt.can_total_amount / mt.can_total_orders, 0), 2) AS avg_can_total_amount - FROM monthly_orders mo - LEFT JOIN monthly_totals mt ON mo.order_date = mt.order_date - LEFT JOIN order_sales_summary os ON mo.order_id = os.orderId - LEFT JOIN order_items_summary oi ON mo.order_id = oi.orderId - GROUP BY - mt.order_date, - mt.total_amount, - mt.togo_total_amount, - mt.can_total_amount, - mt.first_purchase_total, - mt.repeat_purchase_total, - mt.cpc_total, - mt.non_cpc_total, - mt.zyn_total, - mt.non_zyn_total, - mt.yoone_total, - mt.non_yoone_total, - mt.zex_total, - mt.non_zex_total, - mt.direct_total, - mt.organic_total, - mt.total_orders, - mt.togo_total_orders, - mt.can_total_orders - ORDER BY mt.order_date DESC; - `;} - return this.orderRepository.query(sql); } // async getOrderStatistics(params: OrderStatisticsParams) { @@ -1045,10 +656,10 @@ export class StatisticsService { const offset = (current - 1) * pageSize; const countSql = ` WITH product_list AS ( - SELECT DISTINCT s.productSku + SELECT DISTINCT s.sku FROM stock s LEFT JOIN stock_point sp ON s.stockPointId = sp.id - LEFT JOIN product p ON s.productSku = p.sku + LEFT JOIN product p ON s.sku = p.sku WHERE sp.ignore = FALSE ${countnameFilter} ) @@ -1063,27 +674,27 @@ export class StatisticsService { const sql = ` WITH stock_summary AS ( SELECT - s.productSku, + s.sku, JSON_ARRAYAGG(JSON_OBJECT('id', sp.id, 'quantity', s.quantity)) AS stockDetails, SUM(s.quantity) AS totalStock, SUM(CASE WHEN sp.inCanada THEN s.quantity ELSE 0 END) AS caTotalStock FROM stock s JOIN stock_point sp ON s.stockPointId = sp.id WHERE sp.ignore = FALSE - GROUP BY s.productSku + GROUP BY s.sku ), transfer_stock AS ( SELECT - ti.productSku, + ti.sku, SUM(ti.quantity) AS transitStock FROM transfer_item ti JOIN transfer t ON ti.transferId = t.id WHERE t.isCancel = FALSE AND t.isArrived = FALSE - GROUP BY ti.productSku + GROUP BY ti.sku ), 30_sales_summary AS ( SELECT - os.sku AS productSku, + os.sku AS sku, SUM(os.quantity) AS totalSales FROM order_sale os JOIN \`order\` o ON os.orderId = o.id @@ -1093,7 +704,7 @@ export class StatisticsService { ), 15_sales_summary AS ( SELECT - os.sku AS productSku, + os.sku AS sku, 2 * SUM(os.quantity) AS totalSales FROM order_sale os JOIN \`order\` o ON os.orderId = o.id @@ -1103,36 +714,36 @@ export class StatisticsService { ), sales_max_summary AS ( SELECT - s30.productSku AS productSku, + s30.sku AS sku, COALESCE(s30.totalSales, 0) AS totalSales_30, COALESCE(s15.totalSales, 0) AS totalSales_15, GREATEST(COALESCE(s30.totalSales, 0), COALESCE(s15.totalSales, 0)) AS maxSales FROM 30_sales_summary s30 LEFT JOIN 15_sales_summary s15 - ON s30.productSku = s15.productSku + ON s30.sku = s15.sku UNION ALL SELECT - s15.productSku AS productSku, + s15.sku AS sku, 0 AS totalSales_30, COALESCE(s15.totalSales, 0) AS totalSales_15, COALESCE(s15.totalSales, 0) AS maxSales FROM 15_sales_summary s15 LEFT JOIN 30_sales_summary s30 - ON s30.productSku = s15.productSku - WHERE s30.productSku IS NULL + ON s30.sku = s15.sku + WHERE s30.sku IS NULL ), product_name_summary AS ( SELECT - p.sku AS productSku, + p.sku AS sku, COALESCE(MAX(os.name), MAX(p.name)) AS productName FROM product p LEFT JOIN order_sale os ON p.sku = os.sku GROUP BY p.sku ) SELECT - ss.productSku, + ss.sku, ss.stockDetails, COALESCE(ts.transitStock, 0) AS transitStock, (COALESCE(ss.totalStock, 0) + COALESCE(ts.transitStock, 0)) AS totalStock, @@ -1150,9 +761,9 @@ export class StatisticsService { sales.maxSales * 4 AS restockQuantity, pns.productName FROM stock_summary ss - LEFT JOIN transfer_stock ts ON ss.productSku = ts.productSku - LEFT JOIN sales_max_summary sales ON ss.productSku = sales.productSku - LEFT JOIN product_name_summary pns ON ss.productSku = pns.productSku + LEFT JOIN transfer_stock ts ON ss.sku = ts.sku + LEFT JOIN sales_max_summary sales ON ss.sku = sales.sku + LEFT JOIN product_name_summary pns ON ss.sku = pns.sku WHERE 1 = 1 ${nameFilter} ORDER BY caAvailableDays @@ -1180,10 +791,10 @@ export class StatisticsService { const offset = (current - 1) * pageSize; const countSql = ` WITH product_list AS ( - SELECT DISTINCT s.productSku + SELECT DISTINCT s.sku FROM stock s LEFT JOIN stock_point sp ON s.stockPointId = sp.id - LEFT JOIN product p ON s.productSku = p.sku + LEFT JOIN product p ON s.sku = p.sku WHERE sp.ignore = FALSE ${countnameFilter} ) @@ -1199,36 +810,36 @@ export class StatisticsService { const sql = ` WITH stock_summary AS ( SELECT - s.productSku, + s.sku, SUM(s.quantity) AS totalStock FROM stock s JOIN stock_point sp ON s.stockPointId = sp.id WHERE sp.ignore = FALSE - GROUP BY s.productSku + GROUP BY s.sku ), transfer_stock AS ( SELECT - ti.productSku, + ti.sku, SUM(ti.quantity) AS transitStock FROM transfer_item ti JOIN transfer t ON ti.transferId = t.id WHERE t.isCancel = FALSE AND t.isArrived = FALSE - GROUP BY ti.productSku + GROUP BY ti.sku ), b_sales_data_raw As ( SELECT - sr.productSku, + sr.sku, DATE_FORMAT(sr.createdAt, '%Y-%m') AS month, SUM(sr.quantityChange) AS sales FROM stock_record sr JOIN stock_point sp ON sr.stockPointId = sp.id WHERE sp.isB AND sr.createdAt >= DATE_FORMAT(NOW() - INTERVAL 2 MONTH, '%Y-%m-01') - GROUP BY sr.productSku, month + GROUP BY sr.sku, month ), sales_data_raw AS ( SELECT - os.sku AS productSku, + os.sku AS sku, DATE_FORMAT(o.date_paid, '%Y-%m') AS month, SUM(CASE WHEN DAY(o.date_paid) <= 10 THEN os.quantity ELSE 0 END) AS early_sales, SUM(CASE WHEN DAY(o.date_paid) > 10 AND DAY(o.date_paid) <= 20 THEN os.quantity ELSE 0 END) AS mid_sales, @@ -1241,7 +852,7 @@ export class StatisticsService { ), monthly_sales_summary AS ( SELECT - sdr.productSku, + sdr.sku, JSON_ARRAYAGG( JSON_OBJECT( 'month', sdr.month, @@ -1252,12 +863,12 @@ export class StatisticsService { ) ) AS sales_data FROM sales_data_raw sdr - LEFT JOIN b_sales_data_raw b ON sdr.productSku = b.productSku AND sdr.month = b.month - GROUP BY sdr.productSku + LEFT JOIN b_sales_data_raw b ON sdr.sku = b.sku AND sdr.month = b.month + GROUP BY sdr.sku ), sales_summary AS ( SELECT - os.sku AS productSku, + os.sku AS sku, SUM(CASE WHEN o.date_paid >= CURDATE() - INTERVAL 30 DAY THEN os.quantity ELSE 0 END) AS last_30_days_sales, SUM(CASE WHEN o.date_paid >= CURDATE() - INTERVAL 15 DAY THEN os.quantity ELSE 0 END) AS last_15_days_sales, SUM(CASE WHEN DATE_FORMAT(o.date_paid, '%Y-%m') = DATE_FORMAT(CURDATE() - INTERVAL 1 MONTH, '%Y-%m') THEN os.quantity ELSE 0 END) AS last_month_sales @@ -1269,14 +880,14 @@ export class StatisticsService { ), product_name_summary AS ( SELECT - p.sku AS productSku, + p.sku AS sku, COALESCE(MAX(os.name), MAX(p.name)) AS productName FROM product p LEFT JOIN order_sale os ON p.sku = os.sku GROUP BY p.sku ) SELECT - ss.productSku, + ss.sku, (COALESCE(ss.totalStock, 0) + COALESCE(ts.transitStock, 0)) AS totalStock, ms.sales_data AS monthlySalesData, pns.productName, @@ -1289,10 +900,10 @@ export class StatisticsService { ELSE NULL END AS stock_ratio FROM stock_summary ss - LEFT JOIN transfer_stock ts ON ss.productSku = ts.productSku - LEFT JOIN monthly_sales_summary ms ON ss.productSku = ms.productSku - LEFT JOIN product_name_summary pns ON ss.productSku = pns.productSku - LEFT JOIN sales_summary ssum ON ss.productSku = ssum.productSku + LEFT JOIN transfer_stock ts ON ss.sku = ts.sku + LEFT JOIN monthly_sales_summary ms ON ss.sku = ms.sku + LEFT JOIN product_name_summary pns ON ss.sku = pns.sku + LEFT JOIN sales_summary ssum ON ss.sku = ssum.sku WHERE 1 = 1 ${nameFilter} ORDER BY @@ -1400,7 +1011,7 @@ export class StatisticsService { GROUP BY customer_email ), - -- 标注每个用户每月是“新客户”还是“老客户” + -- 标注每个用户每月是"新客户"还是"老客户" labeled_users AS ( SELECT m.customer_email, @@ -1445,7 +1056,7 @@ export class StatisticsService { GROUP BY current_month ) - -- 最终结果:每月新客户、老客户、未来未复购客户 + -- 最终结果:每月新客户,老客户,未来未复购客户 SELECT m.order_month, m.new_user_count, diff --git a/src/service/stock.service.ts b/src/service/stock.service.ts index c730269..bc7d4e1 100644 --- a/src/service/stock.service.ts +++ b/src/service/stock.service.ts @@ -1,5 +1,5 @@ import { Provide } from '@midwayjs/core'; -import { Between, Like, Repository, LessThan, MoreThan } from 'typeorm'; +import { Between, Like, Repository, LessThan, MoreThan, In } from 'typeorm'; import { Stock } from '../entity/stock.entity'; import { StockRecord } from '../entity/stock_record.entity'; import { paginate } from '../utils/paginate.util'; @@ -27,6 +27,7 @@ import { User } from '../entity/user.entity'; import dayjs = require('dayjs'); import { Transfer } from '../entity/transfer.entity'; import { TransferItem } from '../entity/transfer_item.entity'; +import { Area } from '../entity/area.entity'; @Provide() export class StockService { @@ -51,35 +52,55 @@ export class StockService { @InjectEntityModel(TransferItem) transferItemModel: Repository; + @InjectEntityModel(Area) + areaModel: Repository; + async createStockPoint(data: CreateStockPointDTO) { - const { name, location, contactPerson, contactPhone } = data; + const { areas: areaCodes, ...restData } = data; const stockPoint = new StockPoint(); - stockPoint.name = name; - stockPoint.location = location; - stockPoint.contactPerson = contactPerson; - stockPoint.contactPhone = contactPhone; + Object.assign(stockPoint, restData); + + if (areaCodes && areaCodes.length > 0) { + const areas = await this.areaModel.findBy({ code: In(areaCodes) }); + stockPoint.areas = areas; + } else { + stockPoint.areas = []; + } + await this.stockPointModel.save(stockPoint); } async updateStockPoint(id: number, data: UpdateStockPointDTO) { - // 确认产品是否存在 - const point = await this.stockPointModel.findOneBy({ id }); - if (!point) { - throw new Error(`产品 ID ${id} 不存在`); + const { areas: areaCodes, ...restData } = data; + const pointToUpdate = await this.stockPointModel.findOneBy({ id }); + if (!pointToUpdate) { + throw new Error(`仓库点 ID ${id} 不存在`); } - // 更新产品 - await this.stockPointModel.update(id, data); + + Object.assign(pointToUpdate, restData); + + if (areaCodes !== undefined) { + if (areaCodes.length > 0) { + const areas = await this.areaModel.findBy({ code: In(areaCodes) }); + pointToUpdate.areas = areas; + } else { + pointToUpdate.areas = []; + } + } + + await this.stockPointModel.save(pointToUpdate); } async getStockPoints(query: QueryPointDTO) { const { current = 1, pageSize = 10 } = query; return await paginate(this.stockPointModel, { pagination: { current, pageSize }, + relations: ['areas'], }); } async getAllStockPoints(): Promise { - return await this.stockPointModel.find(); + return await this.stockPointModel.find({ relations: ['areas'] }); } async delStockPoints(id: number) { @@ -118,7 +139,7 @@ export class StockService { const purchaseOrder = await this.purchaseOrderModel.findOneBy({ id }); if (!purchaseOrder) throw new Error(`采购订单 ID ${id} 不存在`); if (purchaseOrder.status === 'received') - throw new Error(`采购订单 ID ${id} 已到达,无法修改`); + throw new Error(`采购订单 ID ${id} 已到达,无法修改`); const { stockPointId, expectedArrivalTime, status, items, note } = data; purchaseOrder.stockPointId = stockPointId; purchaseOrder.expectedArrivalTime = expectedArrivalTime; @@ -167,7 +188,7 @@ export class StockService { qb .select([ 'poi.purchaseOrderId AS purchaseOrderId', - "JSON_ARRAYAGG(JSON_OBJECT('id', poi.id, 'productName', poi.productName,'productSku', poi.productSku, 'quantity', poi.quantity, 'price', poi.price)) AS items", + "JSON_ARRAYAGG(JSON_OBJECT('id', poi.id, 'name', poi.name,'sku', poi.sku, 'quantity', poi.quantity, 'price', poi.price)) AS items", ]) .from(PurchaseOrderItem, 'poi') .groupBy('poi.purchaseOrderId'), @@ -187,11 +208,21 @@ export class StockService { ); } + // 检查指定 SKU 是否在任一仓库有库存(数量大于 0) + async hasStockBySku(sku: string): Promise { + const count = await this.stockModel + .createQueryBuilder('stock') + .where('stock.sku = :sku', { sku }) + .andWhere('stock.quantity > 0') + .getCount(); + return count > 0; + } + async delPurchaseOrder(id: number) { const purchaseOrder = await this.purchaseOrderModel.findOneBy({ id }); if (!purchaseOrder) throw new Error(`采购订单 ID ${id} 不存在`); if (purchaseOrder.status === 'received') - throw new Error(`采购订单 ID ${id} 已到达,无法删除`); + throw new Error(`采购订单 ID ${id} 已到达,无法删除`); await this.purchaseOrderItemModel.delete({ purchaseOrderId: id }); await this.purchaseOrderModel.delete({ id }); } @@ -200,14 +231,14 @@ export class StockService { const purchaseOrder = await this.purchaseOrderModel.findOneBy({ id }); if (!purchaseOrder) throw new Error(`采购订单 ID ${id} 不存在`); if (purchaseOrder.status === 'received') - throw new Error(`采购订单 ID ${id} 已到达,不要重复操作`); + throw new Error(`采购订单 ID ${id} 已到达,不要重复操作`); const items = await this.purchaseOrderItemModel.find({ where: { purchaseOrderId: id }, }); for (const item of items) { const updateStock = new UpdateStockDTO(); updateStock.stockPointId = purchaseOrder.stockPointId; - updateStock.productSku = item.productSku; + updateStock.sku = item.sku; updateStock.quantityChange = item.quantity; updateStock.operationType = StockRecordOperationType.IN; updateStock.operatorId = userId; @@ -230,63 +261,106 @@ export class StockService { // 获取库存列表 async getStocks(query: QueryStockDTO) { - const { current = 1, pageSize = 10, productName } = query; - const nameKeywords = productName - ? productName.split(' ').filter(Boolean) + const { current = 1, pageSize = 10, name, sku } = query; + const nameKeywords = name + ? name.split(' ').filter(Boolean) : []; let queryBuilder = this.stockModel .createQueryBuilder('stock') .select([ // 'stock.id as id', - 'stock.productSku as productSku', - 'product.name as productName', - 'product.nameCn as productNameCn', + 'stock.sku as sku', + 'product.name as name', + 'product.nameCn as nameCn', 'JSON_ARRAYAGG(JSON_OBJECT("id", stock.stockPointId, "quantity", stock.quantity)) as stockPoint', 'MIN(stock.updatedAt) as updatedAt', 'MAX(stock.createdAt) as createdAt', ]) - .leftJoin(Product, 'product', 'product.sku = stock.productSku') - .groupBy('stock.productSku') + .leftJoin(Product, 'product', 'product.sku = stock.sku') + .groupBy('stock.sku') .addGroupBy('product.name') .addGroupBy('product.nameCn'); let totalQueryBuilder = this.stockModel .createQueryBuilder('stock') - .select('COUNT(DISTINCT stock.productSku)', 'count') - .leftJoin(Product, 'product', 'product.sku = stock.productSku'); - if (nameKeywords.length) { - nameKeywords.forEach((name, index) => { - queryBuilder.andWhere( - `EXISTS ( - SELECT 1 FROM product p - WHERE p.sku = stock.productSku - AND p.name LIKE :name${index} - )`, - { [`name${index}`]: `%${name}%` } - ); - totalQueryBuilder.andWhere( - `EXISTS ( - SELECT 1 FROM product p - WHERE p.sku = stock.productSku - AND p.name LIKE :name${index} - )`, - { [`name${index}`]: `%${name}%` } - ); + .select('COUNT(DISTINCT stock.sku)', 'count') + .leftJoin(Product, 'product', 'product.sku = stock.sku'); + if (sku || nameKeywords.length) { + const conditions = []; + if (sku) { + conditions.push(`stock.sku LIKE :sku`); + } + if (nameKeywords.length) { + nameKeywords.forEach((name, index) => { + conditions.push(`product.name LIKE :name${index}`); + }); + } + const whereClause = conditions.join(' OR '); + queryBuilder.andWhere(`(${whereClause})`, { + sku: `%${sku}%`, + ...nameKeywords.reduce((acc, name, index) => ({ ...acc, [`name${index}`]: `%${name}%` }), {}), + }); + totalQueryBuilder.andWhere(`(${whereClause})`, { + sku: `%${sku}%`, + ...nameKeywords.reduce((acc, name, index) => ({ ...acc, [`name${index}`]: `%${name}%` }), {}), }); } - const items = await queryBuilder.getRawMany(); - const total = await totalQueryBuilder.getRawOne(); + if (query.order) { + const sortFieldMap: Record = { + name: 'product.name', + sku: 'stock.sku', + updatedAt: 'updatedAt', + createdAt: 'createdAt', + }; + let isFirstSort = true; + Object.entries(query.order).forEach(([field, direction]) => { + const orderDirection = direction === 'asc' ? 'ASC' : 'DESC'; + if (field.startsWith('point_')) { + const pointId = field.split('_')[1]; + const sortExpr = `SUM(CASE WHEN stock.stockPointId = :pointId THEN stock.quantity ELSE 0 END)`; + const sortAlias = `pointSort_${pointId}`; + queryBuilder + .addSelect(sortExpr, sortAlias) + .setParameter('pointId', Number(pointId)); + if (isFirstSort) { + queryBuilder.orderBy(sortAlias, orderDirection); + isFirstSort = false; + } else { + queryBuilder.addOrderBy(sortAlias, orderDirection); + } + } else { + const actualSortField = sortFieldMap[field] || field; + if (isFirstSort) { + queryBuilder.orderBy(actualSortField, orderDirection); + isFirstSort = false; + } else { + queryBuilder.addOrderBy(actualSortField, orderDirection); + } + } + }); + } else { + // 默认按产品名称排序 + queryBuilder.orderBy('product.name', 'ASC'); + } + + const items = await queryBuilder + .offset((current - 1) * pageSize) + .limit(pageSize) + .getRawMany(); + const totalResult = await totalQueryBuilder.getRawOne(); + const total = parseInt(totalResult.count, 10); + const transfer = await this.transferModel .createQueryBuilder('t') - .select(['ti.productSku as productSku', 'SUM(ti.quantity) as quantity']) + .select(['ti.sku as sku', 'SUM(ti.quantity) as quantity']) .leftJoin(TransferItem, 'ti', 'ti.transferId = t.id') .where('!t.isArrived and !t.isCancel and !t.isLost') - .groupBy('ti.productSku') + .groupBy('ti.sku') .getRawMany(); for (const item of items) { item.inTransitQuantity = - transfer.find(t => t.productSku === item.productSku)?.quantity || 0; + transfer.find(t => t.sku === item.sku)?.quantity || 0; } return { @@ -297,11 +371,27 @@ export class StockService { }; } + async getStocksBySkus(skus: string[]) { + if (!skus || skus.length === 0) { + return []; + } + + const stocks = await this.stockModel + .createQueryBuilder('stock') + .select('stock.sku', 'sku') + .addSelect('SUM(stock.quantity)', 'totalQuantity') + .where('stock.sku IN (:...skus)', { skus }) + .groupBy('stock.sku') + .getRawMany(); + + return stocks; + } + // 更新库存 async updateStock(data: UpdateStockDTO) { const { stockPointId, - productSku, + sku, quantityChange, operationType, operatorId, @@ -310,13 +400,13 @@ export class StockService { const stock = await this.stockModel.findOneBy({ stockPointId, - productSku, + sku, }); if (!stock) { - // 如果库存不存在,则直接新增 + // 如果库存不存在,则直接新增 const newStock = this.stockModel.create({ stockPointId, - productSku, + sku, quantity: operationType === 'in' ? quantityChange : -quantityChange, }); await this.stockModel.save(newStock); @@ -325,7 +415,7 @@ export class StockService { stock.quantity += operationType === 'in' ? quantityChange : -quantityChange; // if (stock.quantity < 0) { - // throw new Error('库存不足,无法完成操作'); + // throw new Error('库存不足,无法完成操作'); // } await this.stockModel.save(stock); } @@ -333,7 +423,7 @@ export class StockService { // 记录库存变更日志 const stockRecord = this.stockRecordModel.create({ stockPointId, - productSku, + sku, operationType, quantityChange, operatorId, @@ -348,8 +438,8 @@ export class StockService { current = 1, pageSize = 10, stockPointId, - productSku, - productName, + sku, + name, operationType, startDate, endDate, @@ -357,14 +447,14 @@ export class StockService { const where: any = {}; if (stockPointId) where.stockPointId = stockPointId; - if (productSku) where.productSku = productSku; + if (sku) where.sku = sku; if (operationType) where.operationType = operationType; if (startDate) where.createdAt = MoreThan(startDate); if (endDate) where.createdAt = LessThan(endDate); if (startDate && endDate) where.createdAt = Between(startDate, endDate); const queryBuilder = this.stockRecordModel .createQueryBuilder('stock_record') - .leftJoin(Product, 'product', 'product.sku = stock_record.productSku') + .leftJoin(Product, 'product', 'product.sku = stock_record.sku') .leftJoin(User, 'user', 'stock_record.operatorId = user.id') .leftJoin(StockPoint, 'sp', 'sp.id = stock_record.stockPointId') .select([ @@ -374,9 +464,9 @@ export class StockService { 'sp.name as stockPointName', ]) .where(where); - if (productName) + if (name) queryBuilder.andWhere('product.name LIKE :name', { - name: `%${productName}%`, + name: `%${name}%`, }); const items = await queryBuilder .orderBy('stock_record.createdAt', 'DESC') @@ -397,7 +487,7 @@ export class StockService { // for (const item of items) { // const stock = await this.stockModel.findOneBy({ // stockPointId: sourceStockPointId, - // productSku: item.productSku, + // sku: item.sku, // }); // if (!stock || stock.quantity < item.quantity) // throw new Error(`${item.productName} 库存不足`); @@ -423,7 +513,7 @@ export class StockService { item.transferId = transfer.id; const updateStock = new UpdateStockDTO(); updateStock.stockPointId = sourceStockPointId; - updateStock.productSku = item.productSku; + updateStock.sku = item.sku; updateStock.quantityChange = item.quantity; updateStock.operationType = StockRecordOperationType.OUT; updateStock.operatorId = userId; @@ -457,7 +547,7 @@ export class StockService { qb .select([ 'ti.transferId AS transferId', - "JSON_ARRAYAGG(JSON_OBJECT('id', ti.id, 'productName', ti.productName,'productSku', ti.productSku, 'quantity', ti.quantity)) AS items", + "JSON_ARRAYAGG(JSON_OBJECT('id', ti.id, 'productName', ti.productName,'sku', ti.sku, 'quantity', ti.quantity)) AS items", ]) .from(TransferItem, 'ti') .groupBy('ti.transferId'), @@ -481,14 +571,14 @@ export class StockService { async cancelTransfer(id: number, userId: number) { const transfer = await this.transferModel.findOneBy({ id }); if (!transfer) throw new Error(`调拨 ID ${id} 不存在`); - if (transfer.isArrived) throw new Error(`调拨 ID ${id} 已到达,无法取消`); + if (transfer.isArrived) throw new Error(`调拨 ID ${id} 已到达,无法取消`); const items = await this.transferItemModel.find({ where: { transferId: id }, }); for (const item of items) { const updateStock = new UpdateStockDTO(); updateStock.stockPointId = transfer.sourceStockPointId; - updateStock.productSku = item.productSku; + updateStock.sku = item.sku; updateStock.quantityChange = item.quantity; updateStock.operationType = StockRecordOperationType.IN; updateStock.operatorId = userId; @@ -504,14 +594,14 @@ export class StockService { if (!transfer) throw new Error(`调拨 ID ${id} 不存在`); if (transfer.isCancel) throw new Error(`调拨 ID ${id} 已取消`); if (transfer.isArrived) - throw new Error(`调拨 ID ${id} 已到达,不要重复操作`); + throw new Error(`调拨 ID ${id} 已到达,不要重复操作`); const items = await this.transferItemModel.find({ where: { transferId: id }, }); for (const item of items) { const updateStock = new UpdateStockDTO(); updateStock.stockPointId = transfer.destStockPointId; - updateStock.productSku = item.productSku; + updateStock.sku = item.sku; updateStock.quantityChange = item.quantity; updateStock.operationType = StockRecordOperationType.IN; updateStock.operatorId = userId; @@ -527,7 +617,7 @@ export class StockService { if (!transfer) throw new Error(`调拨 ID ${id} 不存在`); if (transfer.isCancel) throw new Error(`调拨 ID ${id} 已取消`); if (transfer.isArrived) - throw new Error(`调拨 ID ${id} 已到达,不要重复操作`); + throw new Error(`调拨 ID ${id} 已到达,不要重复操作`); transfer.isLost = true; await this.transferModel.save(transfer); } @@ -546,7 +636,7 @@ export class StockService { item.transferId = transfer.id; const updateStock = new UpdateStockDTO(); updateStock.stockPointId = sourceStockPointId; - updateStock.productSku = item.productSku; + updateStock.sku = item.sku; updateStock.quantityChange = item.quantity; updateStock.operationType = StockRecordOperationType.IN; updateStock.operatorId = userId; @@ -559,7 +649,7 @@ export class StockService { item.transferId = transfer.id; const updateStock = new UpdateStockDTO(); updateStock.stockPointId = sourceStockPointId; - updateStock.productSku = item.productSku; + updateStock.sku = item.sku; updateStock.quantityChange = item.quantity; updateStock.operationType = StockRecordOperationType.OUT; updateStock.operatorId = userId; diff --git a/src/service/subscription.service.ts b/src/service/subscription.service.ts index 5ae83b0..2f9fda1 100644 --- a/src/service/subscription.service.ts +++ b/src/service/subscription.service.ts @@ -19,19 +19,38 @@ export class SubscriptionService { * 同步指定站点的订阅列表 * - 从 WooCommerce 拉取订阅并逐条入库/更新 */ - async syncSubscriptions(siteId: string) { - const subs = await this.wpService.getSubscriptions(siteId); - for (const sub of subs) { - await this.syncSingleSubscription(siteId, sub); + async syncSubscriptions(siteId: number) { + try { + const subs = await this.wpService.getSubscriptions(siteId); + let successCount = 0; + let failureCount = 0; + for (const sub of subs) { + try { + await this.syncSingleSubscription(siteId, sub); + successCount++; + } catch (error) { + console.error(`同步订阅 ${sub.id} 失败:`, error); + failureCount++; + } + } + return { + success: failureCount === 0, + successCount, + failureCount, + message: `同步完成: 成功 ${successCount}, 失败 ${failureCount}`, + }; + } catch (error) { + console.error('同步订阅失败:', error); + return { success: false, successCount: 0, failureCount: 0, message: `同步失败: ${error.message}` }; } } /** * 同步单条订阅 - * - 规范化字段、设置幂等键 externalSubscriptionId - * - 已存在则更新,不存在则新增 + * - 规范化字段,设置幂等键 externalSubscriptionId + * - 已存在则更新,不存在则新增 */ - async syncSingleSubscription(siteId: string, sub: any) { + async syncSingleSubscription(siteId: number, sub: any) { const { line_items, ...raw } = sub; const entity: Partial = { ...raw, @@ -43,7 +62,7 @@ export class SubscriptionService { }; delete (entity as any).id; const existing = await this.subscriptionModel.findOne({ - where: { externalSubscriptionId: String(sub.id), siteId }, + where: { externalSubscriptionId: String(sub.id), siteId: siteId }, }); const saveEntity = plainToClass(Subscription, entity); if (existing) { @@ -54,7 +73,7 @@ export class SubscriptionService { } /** - * 获取订阅分页列表(支持站点、状态、邮箱与关键字筛选) + * 获取订阅分页列表(支持站点,状态,邮箱与关键字筛选) */ async getSubscriptionList({ current = 1, diff --git a/src/service/template.service.ts b/src/service/template.service.ts new file mode 100644 index 0000000..bdb5e7d --- /dev/null +++ b/src/service/template.service.ts @@ -0,0 +1,162 @@ +import { Provide } from '@midwayjs/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { Template } from '../entity/template.entity'; +import { CreateTemplateDTO, UpdateTemplateDTO } from '../dto/template.dto'; +import { Eta } from 'eta'; +import { generateTestDataFromEta } from '../utils/testdata.util'; + +/** + * @service TemplateService 模板服务 + */ +@Provide() +export class TemplateService { + private eta = new Eta(); + + // 注入 Template 实体模型 + @InjectEntityModel(Template) + templateModel: Repository