feat: add yuntu report filling automation
This commit is contained in:
parent
c0a531fd1d
commit
c8b5d27078
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
**/node_modules/
|
**/node_modules/
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
**/.env
|
||||||
|
|||||||
69
docs/superpowers/plans/2026-03-31-yuntu-report-server.md
Normal file
69
docs/superpowers/plans/2026-03-31-yuntu-report-server.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Yuntu Report Server Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a local Node.js backend that exposes `POST /api/reports` for the browser script and persists validated report records into PostgreSQL.
|
||||||
|
|
||||||
|
**Architecture:** Use a small Express server with a thin HTTP layer, isolated validation/mapping utilities, and a PostgreSQL repository backed by parameterized SQL. Keep report validation and database row mapping in pure functions so they can be covered with fast `node:test` tests before wiring the HTTP server.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js, Express, PostgreSQL (`pg`), `dotenv`, built-in `node:test`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Bootstrap the local server package
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/package.json`
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/.env.example`
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the package manifest**
|
||||||
|
- [ ] **Step 2: Add local development scripts for start, dev, and test**
|
||||||
|
- [ ] **Step 3: Add `.env.example` documenting required database and server variables**
|
||||||
|
- [ ] **Step 4: Add a minimal README with startup instructions**
|
||||||
|
|
||||||
|
### Task 2: Write failing tests for validation and mapping
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/test/report-service.test.js`
|
||||||
|
- Test: `yuntu/yuntuReportFilling/server/test/report-service.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a test for accepting a valid manual report payload**
|
||||||
|
- [ ] **Step 2: Run the test and verify it fails because the module does not exist yet**
|
||||||
|
- [ ] **Step 3: Write a test for rejecting `AUTO_COPY` without `sourceReportId`**
|
||||||
|
- [ ] **Step 4: Write a test for mapping API payload fields into database-ready column values**
|
||||||
|
|
||||||
|
### Task 3: Implement the pure report service module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/src/report-service.js`
|
||||||
|
- Test: `yuntu/yuntuReportFilling/server/test/report-service.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement payload validation helpers**
|
||||||
|
- [ ] **Step 2: Implement normalization/mapping helpers for database insertion**
|
||||||
|
- [ ] **Step 3: Re-run the focused test suite and verify it passes**
|
||||||
|
|
||||||
|
### Task 4: Implement the database repository and HTTP server
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/src/db.js`
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/src/report-repository.js`
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/src/server.js`
|
||||||
|
- Create: `yuntu/yuntuReportFilling/server/src/index.js`
|
||||||
|
- Modify: `yuntu/yuntuReportFilling/PRD.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add PostgreSQL pool creation from environment variables**
|
||||||
|
- [ ] **Step 2: Add repository insert logic with idempotent `report_id` handling**
|
||||||
|
- [ ] **Step 3: Add Express routes for `GET /health` and `POST /api/reports`**
|
||||||
|
- [ ] **Step 4: Add CORS and JSON parsing suitable for local browser-script access**
|
||||||
|
- [ ] **Step 5: Re-run tests and add any missing focused coverage**
|
||||||
|
|
||||||
|
### Task 5: Verify the local backend end-to-end
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: `yuntu/yuntuReportFilling/server/*`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install dependencies**
|
||||||
|
- [ ] **Step 2: Run `npm test` and confirm all tests pass**
|
||||||
|
- [ ] **Step 3: Start the server locally and confirm `GET /health` returns success**
|
||||||
|
- [ ] **Step 4: Summarize required next step for integrating the browser script**
|
||||||
405
yuntu/yuntuReportFilling/PRD.md
Normal file
405
yuntu/yuntuReportFilling/PRD.md
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
# 云图报告自动创建 PRD
|
||||||
|
|
||||||
|
## 一、项目基本信息
|
||||||
|
|
||||||
|
- 项目路径:`/Users/wxs/projects/browser_script/scriptCat/yuntu/yuntuReportFilling`
|
||||||
|
- 项目类型:ScriptCat 浏览器脚本 + Node.js 后端服务 + PostgreSQL
|
||||||
|
- 业务目标:在云图页面中复用最近一次成功创建报告的请求参数,自动创建一份同比报告,并将关键数据持久化到数据库
|
||||||
|
- 核心价值:
|
||||||
|
- 降低人工重复创建报告的成本
|
||||||
|
- 自动将时间范围回退一年,方便做同比分析
|
||||||
|
- 为后续追溯和排查保留完整 payload 与 report_id
|
||||||
|
|
||||||
|
## 二、业务背景
|
||||||
|
|
||||||
|
当前用户在云图页面手动创建报告后,还需要再次输入或复制相同配置,才能创建同比报告。该过程重复且容易出错。
|
||||||
|
|
||||||
|
本项目通过监听用户首次手动创建报告时的请求与响应,缓存最近一次成功的 payload;当用户点击右侧按钮后,脚本自动将顶层 `startTime` 与 `endTime` 各回退一年,并再次调用云图创建接口完成报告复制。
|
||||||
|
|
||||||
|
## 三、范围说明
|
||||||
|
|
||||||
|
### 本期范围
|
||||||
|
|
||||||
|
- 监听并识别云图“创建报告”接口
|
||||||
|
- 记录最近一次成功创建报告的完整 payload
|
||||||
|
- 在页面右侧提供“一键复制同比报告”按钮
|
||||||
|
- 自动回退顶层 `startTime` / `endTime` 一年
|
||||||
|
- 调用后端 API 持久化成功创建的报告数据
|
||||||
|
- 将手动创建报告和脚本自动创建报告统一写入 PostgreSQL
|
||||||
|
|
||||||
|
### 本期不做
|
||||||
|
|
||||||
|
- 不从数据库反向同步最新 payload 到浏览器本地
|
||||||
|
- 不支持多条历史 payload 管理
|
||||||
|
- 不支持用户手动编辑复制前的字段
|
||||||
|
- 不修改除顶层 `startTime` / `endTime` 外的其他字段
|
||||||
|
|
||||||
|
## 四、目标页面与第三方接口
|
||||||
|
|
||||||
|
### 4.1 目标页面
|
||||||
|
|
||||||
|
- 页面域名:`https://yuntu.oceanengine.com`
|
||||||
|
- 目标页面路径:`/yuntu_brand/ecom/product/segmentedMarketcreation`
|
||||||
|
- 页面示例:`https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation?aadvid=1648829117232140`
|
||||||
|
|
||||||
|
### 4.2 需监听与复用的云图接口
|
||||||
|
|
||||||
|
- 请求方法:`POST`
|
||||||
|
- 接口路径:`/product_node/v2/api/segmentedMarket/createSegmentedMarket`
|
||||||
|
- 完整示例:
|
||||||
|
- `https://yuntu.oceanengine.com/product_node/v2/api/segmentedMarket/createSegmentedMarket?aadvid=1648829117232140`
|
||||||
|
- 关键识别条件:
|
||||||
|
- 请求方法必须为 `POST`
|
||||||
|
- 路径必须匹配 `createSegmentedMarket`
|
||||||
|
- 查询参数中包含 `aadvid`
|
||||||
|
|
||||||
|
### 4.3 第三方接口约束
|
||||||
|
|
||||||
|
- 浏览器脚本调用该接口时,必须复用当前用户浏览器会话中的登录态
|
||||||
|
- 不允许在脚本或后端中硬编码 Cookie、CSRF Token 等敏感信息
|
||||||
|
- 只允许浏览器脚本直接调用云图接口;后端不直接代调云图接口
|
||||||
|
- 接口成功后,从响应体 `data.reportId` 中提取业务 `report_id`
|
||||||
|
|
||||||
|
## 五、核心流程
|
||||||
|
|
||||||
|
### 5.1 手动创建报告并记录
|
||||||
|
|
||||||
|
触发时机:用户在页面中手动点击“创建报告”并且云图接口返回成功。
|
||||||
|
|
||||||
|
执行流程:
|
||||||
|
|
||||||
|
1. 监听 `createSegmentedMarket` 请求与响应。
|
||||||
|
2. 提取请求 payload 与请求 URL 中的 `aadvid`。
|
||||||
|
3. 校验响应成功,且响应体存在 `data.reportId`。
|
||||||
|
4. 将以下内容写入浏览器本地:
|
||||||
|
- `lastReportPayload`:最近一次成功创建报告的完整 payload
|
||||||
|
- `lastReportMeta`:最近一次成功创建报告的元信息,至少包含 `reportId`、`aadvid`、`sourceType`、`capturedAt`
|
||||||
|
5. 如果本地写入失败,立即报错,并保持按钮不可用状态;本次不调用后端持久化接口。
|
||||||
|
6. 如果本地写入成功,调用后端 API 将本次手动创建报告写入数据库。
|
||||||
|
|
||||||
|
### 5.2 一键复制同比报告
|
||||||
|
|
||||||
|
触发时机:用户点击页面右侧“一键复制同比报告”按钮。
|
||||||
|
|
||||||
|
执行流程:
|
||||||
|
|
||||||
|
1. 读取 `lastReportPayload` 与 `lastReportMeta`。
|
||||||
|
2. 若本地不存在有效 payload,则按钮保持置灰,不允许点击。
|
||||||
|
3. 深拷贝 payload,仅修改顶层 `startTime` 和 `endTime`。
|
||||||
|
4. 使用修改后的 payload 调用云图创建接口。
|
||||||
|
5. 接口成功后,从响应体中读取新的 `data.reportId`。
|
||||||
|
6. 先将新的 payload 和元信息覆盖写入本地存储。
|
||||||
|
7. 本地写入成功后,调用后端 API 持久化本次自动复制创建的报告数据。
|
||||||
|
8. 持久化成功后,给出成功提示,并显示新的 `report_id`。
|
||||||
|
|
||||||
|
失败处理:
|
||||||
|
|
||||||
|
- 云图接口调用失败:提示“同比报告创建失败”,保留旧的本地 payload,不调用后端 API。
|
||||||
|
- 本地覆盖写入失败:提示“本地缓存更新失败”,不调用后端 API,按钮恢复可点击。
|
||||||
|
- 后端持久化失败:提示“报告已创建成功,但数据库记录失败”,保留新的本地 payload,按钮恢复可点击。
|
||||||
|
|
||||||
|
## 六、时间处理规则
|
||||||
|
|
||||||
|
### 6.1 处理目标
|
||||||
|
|
||||||
|
将顶层 `startTime` 与 `endTime` 各回退一个自然年,用于生成同比报告。
|
||||||
|
|
||||||
|
### 6.2 处理原则
|
||||||
|
|
||||||
|
- 只处理 payload 顶层字段:
|
||||||
|
- `startTime`
|
||||||
|
- `endTime`
|
||||||
|
- 其他字段保持原值不变
|
||||||
|
- 日期格式统一为 `YYYY-MM-DD`
|
||||||
|
- 使用“自然年回退”规则,而不是简单减去 365 天
|
||||||
|
|
||||||
|
### 6.3 具体算法
|
||||||
|
|
||||||
|
1. 将 `startTime`、`endTime` 解析为日期对象。
|
||||||
|
2. 分别将年份减 1,尽量保持原始月与日不变。
|
||||||
|
3. 如果目标年份不存在该日期,则回退到目标月份最后一天。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 正常日期
|
||||||
|
2025-03-01 -> 2024-03-01
|
||||||
|
2026-02-28 -> 2025-02-28
|
||||||
|
|
||||||
|
// 闰年特殊情况
|
||||||
|
2024-02-29 -> 2023-02-28
|
||||||
|
```
|
||||||
|
|
||||||
|
该规则可以最大限度保持同比区间的自然日语义。
|
||||||
|
|
||||||
|
## 七、页面交互设计
|
||||||
|
|
||||||
|
### 7.1 按钮位置
|
||||||
|
|
||||||
|
- 按钮固定显示在页面右侧中部
|
||||||
|
- 文案建议:`一键复制同比报告`
|
||||||
|
|
||||||
|
### 7.2 按钮状态
|
||||||
|
|
||||||
|
- 默认状态:
|
||||||
|
- 当本地存在有效 `lastReportPayload` 时可点击
|
||||||
|
- 当本地不存在有效 `lastReportPayload` 时置灰且不可点击
|
||||||
|
- 加载状态:
|
||||||
|
- 点击后立即进入 loading
|
||||||
|
- loading 期间禁止再次点击,防止重复创建
|
||||||
|
- 完成状态:
|
||||||
|
- 成功后 toast 提示创建成功,并展示新 `report_id`
|
||||||
|
- 成功后不自动跳转页面,保持用户留在当前页面,避免打断连续操作
|
||||||
|
|
||||||
|
### 7.3 状态恢复规则
|
||||||
|
|
||||||
|
- 用户再次手动成功创建报告后,按钮状态根据本地缓存重新计算
|
||||||
|
- 任何失败场景都需要正确退出 loading 状态
|
||||||
|
|
||||||
|
## 八、浏览器本地存储设计
|
||||||
|
|
||||||
|
### 8.1 存储方式
|
||||||
|
|
||||||
|
- 使用 `GM_setValue` / `GM_getValue`
|
||||||
|
|
||||||
|
### 8.2 存储键
|
||||||
|
|
||||||
|
- `lastReportPayload`
|
||||||
|
- `lastReportMeta`
|
||||||
|
|
||||||
|
### 8.3 存储内容
|
||||||
|
|
||||||
|
`lastReportPayload`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"endTime": "2026-02-28",
|
||||||
|
"name": "测试",
|
||||||
|
"periodType": "MONTH",
|
||||||
|
"startTime": "2025-03-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`lastReportMeta`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reportId": "123456789",
|
||||||
|
"aadvid": "1648829117232140",
|
||||||
|
"sourceType": "MANUAL_CAPTURE",
|
||||||
|
"capturedAt": "2026-03-31T15:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 更新规则
|
||||||
|
|
||||||
|
- 每次成功创建报告后覆盖写入
|
||||||
|
- 始终只保留最近一次成功创建报告的数据
|
||||||
|
- 脚本启动时不从数据库同步数据
|
||||||
|
|
||||||
|
## 九、后端 API 设计
|
||||||
|
|
||||||
|
后端职责仅为持久化成功创建的报告数据,不直接调用云图接口。
|
||||||
|
|
||||||
|
### 9.1 保存报告记录
|
||||||
|
|
||||||
|
- 方法:`POST`
|
||||||
|
- 路径:`/api/reports`
|
||||||
|
- 用途:保存一条已创建成功的报告记录
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reportId": "123456789",
|
||||||
|
"sourceType": "AUTO_COPY",
|
||||||
|
"sourceReportId": "987654321",
|
||||||
|
"aadvid": "1648829117232140",
|
||||||
|
"name": "测试",
|
||||||
|
"price": ["1,100", "101,100000"],
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"keywords": ["奶粉"],
|
||||||
|
"op": "INCLUDE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"analysisDims": [
|
||||||
|
"MARKETOVERVIEW",
|
||||||
|
"PRODUCTINSIGHT",
|
||||||
|
"MARKETCOMPOSING",
|
||||||
|
"CONSUMERINSIGHT"
|
||||||
|
],
|
||||||
|
"categories": [],
|
||||||
|
"channels": ["ALL"],
|
||||||
|
"startTime": "2024-03-01",
|
||||||
|
"endTime": "2025-02-28",
|
||||||
|
"periodType": "MONTH",
|
||||||
|
"userName": "qichumiaosi@163.com",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `reportId`:云图响应中的 `data.reportId`
|
||||||
|
- `sourceType`:取值为 `MANUAL_CAPTURE` 或 `AUTO_COPY`
|
||||||
|
- `sourceReportId`:
|
||||||
|
- `MANUAL_CAPTURE` 时可为空
|
||||||
|
- `AUTO_COPY` 时必填,表示复制来源报告 ID
|
||||||
|
- `aadvid`:从页面 URL 或请求 URL 中提取
|
||||||
|
- `payload`:完整请求体,原样保存
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"reportId": "123456789"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "sourceReportId is required when sourceType is AUTO_COPY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 后端校验规则
|
||||||
|
|
||||||
|
- `reportId` 必填
|
||||||
|
- `sourceType` 必须为 `MANUAL_CAPTURE` 或 `AUTO_COPY`
|
||||||
|
- `AUTO_COPY` 场景下 `sourceReportId` 必填
|
||||||
|
- `payload` 必须为合法 JSON
|
||||||
|
- `startTime`、`endTime` 必须符合 `YYYY-MM-DD`
|
||||||
|
- 数据库以 `report_id` 做唯一约束,重复写入时返回成功并提示已存在,保证接口幂等
|
||||||
|
|
||||||
|
## 十、数据库设计
|
||||||
|
|
||||||
|
### 10.1 表设计说明
|
||||||
|
|
||||||
|
表名:`public.yuntu_report_info`
|
||||||
|
|
||||||
|
字段设计目标:
|
||||||
|
|
||||||
|
- 记录云图返回的业务 `report_id`
|
||||||
|
- 区分手动创建和自动复制
|
||||||
|
- 能追溯自动复制来源
|
||||||
|
- 保存结构化字段与完整 payload
|
||||||
|
|
||||||
|
### 10.2 建表 SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP TABLE IF EXISTS public.yuntu_report_info;
|
||||||
|
|
||||||
|
CREATE TABLE public.yuntu_report_info (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
report_id VARCHAR(64) NOT NULL,
|
||||||
|
source_type VARCHAR(32) NOT NULL,
|
||||||
|
source_report_id VARCHAR(64) NULL,
|
||||||
|
aadvid VARCHAR(32) NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
price JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
analysis_dims JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
transaction_channels JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
start_time DATE NOT NULL,
|
||||||
|
end_time DATE NOT NULL,
|
||||||
|
period_type VARCHAR(32) NULL,
|
||||||
|
user_name TEXT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_yuntu_report_info_report_id UNIQUE (report_id),
|
||||||
|
CONSTRAINT chk_yuntu_report_info_source_type
|
||||||
|
CHECK (source_type IN ('MANUAL_CAPTURE', 'AUTO_COPY')),
|
||||||
|
CONSTRAINT chk_yuntu_report_info_copy_source
|
||||||
|
CHECK (
|
||||||
|
(source_type = 'MANUAL_CAPTURE' AND source_report_id IS NULL)
|
||||||
|
OR
|
||||||
|
(source_type = 'AUTO_COPY' AND source_report_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_yuntu_report_info_source_report_id
|
||||||
|
ON public.yuntu_report_info (source_report_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_yuntu_report_info_created_at
|
||||||
|
ON public.yuntu_report_info (created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX idx_yuntu_report_info_aadvid_created_at
|
||||||
|
ON public.yuntu_report_info (aadvid, created_at DESC);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_yuntu_report_info_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_yuntu_report_info_updated_at ON public.yuntu_report_info;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_yuntu_report_info_updated_at
|
||||||
|
BEFORE UPDATE ON public.yuntu_report_info
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.set_yuntu_report_info_updated_at();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 字段映射
|
||||||
|
|
||||||
|
| 数据库字段 | 来源字段 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `report_id` | `response.data.reportId` | 云图报告 ID |
|
||||||
|
| `source_type` | 脚本内部标记 | 手动捕获或自动复制 |
|
||||||
|
| `source_report_id` | `lastReportMeta.reportId` | 自动复制来源报告 ID |
|
||||||
|
| `aadvid` | query 参数 `aadvid` | 广告主 ID |
|
||||||
|
| `name` | `payload.name` | 报告名称 |
|
||||||
|
| `price` | `payload.price` | 价格区间 |
|
||||||
|
| `rules` | `payload.rules` | 规则配置 |
|
||||||
|
| `analysis_dims` | `payload.analysisDims` | 分析维度 |
|
||||||
|
| `categories` | `payload.categories` | 类目树 |
|
||||||
|
| `transaction_channels` | `payload.channels` | 渠道信息 |
|
||||||
|
| `start_time` | `payload.startTime` | 报告开始日期 |
|
||||||
|
| `end_time` | `payload.endTime` | 报告结束日期 |
|
||||||
|
| `period_type` | `payload.periodType` | 周期类型 |
|
||||||
|
| `user_name` | `payload.userName` | 用户名 |
|
||||||
|
| `payload` | 完整 payload | 原始请求体 |
|
||||||
|
|
||||||
|
## 十一、错误处理
|
||||||
|
|
||||||
|
### 11.1 浏览器脚本侧
|
||||||
|
|
||||||
|
- 拦截不到目标接口:不报错,不显示成功态
|
||||||
|
- 响应中缺少 `data.reportId`:提示“未获取到 report_id,无法记录”
|
||||||
|
- 本地存储失败:立即报错,并终止当前脚本链路
|
||||||
|
- 用户重复点击:忽略后续点击并保持 loading
|
||||||
|
- 请求超时:提示失败并恢复按钮
|
||||||
|
|
||||||
|
### 11.2 后端侧
|
||||||
|
|
||||||
|
- 参数校验失败:返回 4xx 与明确错误信息
|
||||||
|
- 数据库写入失败:返回 5xx 与错误码
|
||||||
|
- 重复 `report_id` 写入:按幂等成功处理,不重复插入
|
||||||
|
|
||||||
|
## 十二、验收标准
|
||||||
|
|
||||||
|
1. 用户在目标页面手动成功创建报告后,脚本能够识别目标接口,并从响应体中正确提取 `data.reportId`。
|
||||||
|
2. 手动创建成功后,本地必须成功写入 `lastReportPayload` 和 `lastReportMeta`;若写入失败,页面明确报错且按钮不可点击。
|
||||||
|
3. 手动创建成功且本地写入成功后,后端数据库新增一条 `source_type = 'MANUAL_CAPTURE'` 的记录,且 `report_id` 与云图响应一致。
|
||||||
|
4. 当本地不存在有效 payload 时,右侧按钮必须置灰且无法点击。
|
||||||
|
5. 点击“一键复制同比报告”后,脚本只能修改顶层 `startTime` 和 `endTime`,其他字段与来源 payload 保持一致。
|
||||||
|
6. 自动复制成功后,新报告的时间范围必须按自然年回退一年的规则生成;闰年日期按目标月最后一天处理。
|
||||||
|
7. 自动复制成功后,本地缓存必须被新 payload 和新 `reportId` 覆盖;数据库新增一条 `source_type = 'AUTO_COPY'` 的记录,且 `source_report_id` 正确指向来源报告。
|
||||||
|
8. 点击按钮进入 loading 后,用户无法重复触发创建;无论成功还是失败,流程结束后按钮状态必须恢复正确。
|
||||||
|
9. 后端接口重复接收同一个 `report_id` 时,不得插入重复数据。
|
||||||
|
10. 整个方案不得在代码或配置中硬编码用户 Cookie、Token 等敏感登录信息。
|
||||||
3
yuntu/yuntuReportFilling/server/.env.example
Normal file
3
yuntu/yuntuReportFilling/server/.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/yuntu_report
|
||||||
|
ALLOWED_ORIGIN=https://yuntu.oceanengine.com
|
||||||
18
yuntu/yuntuReportFilling/server/README.md
Normal file
18
yuntu/yuntuReportFilling/server/README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Yuntu Report Local Server
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env`.
|
||||||
|
2. Update `DATABASE_URL` to point at your local PostgreSQL instance.
|
||||||
|
3. Optionally adjust `ALLOWED_ORIGIN` if you need to call the server from another origin.
|
||||||
|
4. Install dependencies with `npm install`.
|
||||||
|
5. Start the server with `npm run dev` or `npm start`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `POST /api/reports`
|
||||||
|
|
||||||
|
## Default URL
|
||||||
|
|
||||||
|
- `http://localhost:3000`
|
||||||
1014
yuntu/yuntuReportFilling/server/package-lock.json
generated
Normal file
1014
yuntu/yuntuReportFilling/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
yuntu/yuntuReportFilling/server/package.json
Normal file
18
yuntu/yuntuReportFilling/server/package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "yuntu-report-filling-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Local backend for persisting Yuntu report creation records",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"pg": "^8.13.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
yuntu/yuntuReportFilling/server/sql/schema.sql
Normal file
55
yuntu/yuntuReportFilling/server/sql/schema.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
DROP TABLE IF EXISTS public.yuntu_report_info;
|
||||||
|
|
||||||
|
CREATE TABLE public.yuntu_report_info (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
report_id VARCHAR(64) NOT NULL,
|
||||||
|
source_type VARCHAR(32) NOT NULL,
|
||||||
|
source_report_id VARCHAR(64) NULL,
|
||||||
|
aadvid VARCHAR(32) NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
price JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
analysis_dims JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
transaction_channels JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
start_time DATE NOT NULL,
|
||||||
|
end_time DATE NOT NULL,
|
||||||
|
period_type VARCHAR(32) NULL,
|
||||||
|
user_name TEXT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_yuntu_report_info_report_id UNIQUE (report_id),
|
||||||
|
CONSTRAINT chk_yuntu_report_info_source_type
|
||||||
|
CHECK (source_type IN ('MANUAL_CAPTURE', 'AUTO_COPY')),
|
||||||
|
CONSTRAINT chk_yuntu_report_info_copy_source
|
||||||
|
CHECK (
|
||||||
|
(source_type = 'MANUAL_CAPTURE' AND source_report_id IS NULL)
|
||||||
|
OR
|
||||||
|
(source_type = 'AUTO_COPY' AND source_report_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_yuntu_report_info_source_report_id
|
||||||
|
ON public.yuntu_report_info (source_report_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_yuntu_report_info_created_at
|
||||||
|
ON public.yuntu_report_info (created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX idx_yuntu_report_info_aadvid_created_at
|
||||||
|
ON public.yuntu_report_info (aadvid, created_at DESC);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_yuntu_report_info_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_yuntu_report_info_updated_at ON public.yuntu_report_info;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_yuntu_report_info_updated_at
|
||||||
|
BEFORE UPDATE ON public.yuntu_report_info
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.set_yuntu_report_info_updated_at();
|
||||||
15
yuntu/yuntuReportFilling/server/src/db.js
Normal file
15
yuntu/yuntuReportFilling/server/src/db.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
function createPool(env = process.env) {
|
||||||
|
if (!env.DATABASE_URL) {
|
||||||
|
throw new Error('DATABASE_URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Pool({
|
||||||
|
connectionString: env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPool,
|
||||||
|
};
|
||||||
36
yuntu/yuntuReportFilling/server/src/index.js
Normal file
36
yuntu/yuntuReportFilling/server/src/index.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
const { createPool } = require('./db');
|
||||||
|
const { createRepository } = require('./report-repository');
|
||||||
|
const { createApp } = require('./server');
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const port = Number.parseInt(process.env.PORT || '3000', 10);
|
||||||
|
const allowedOrigin = process.env.ALLOWED_ORIGIN || 'https://yuntu.oceanengine.com';
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const pool = createPool(process.env);
|
||||||
|
const repository = createRepository(pool);
|
||||||
|
const app = createApp({ repository, allowedOrigin });
|
||||||
|
|
||||||
|
const server = app.listen(port, () => {
|
||||||
|
console.log(`Yuntu report server listening on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
server.close(async () => {
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((error) => {
|
||||||
|
console.error('Failed to start Yuntu report server');
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
86
yuntu/yuntuReportFilling/server/src/report-repository.js
Normal file
86
yuntu/yuntuReportFilling/server/src/report-repository.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
const INSERT_REPORT_SQL = `
|
||||||
|
INSERT INTO public.yuntu_report_info (
|
||||||
|
report_id,
|
||||||
|
source_type,
|
||||||
|
source_report_id,
|
||||||
|
aadvid,
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
rules,
|
||||||
|
analysis_dims,
|
||||||
|
categories,
|
||||||
|
transaction_channels,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
period_type,
|
||||||
|
user_name,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
|
||||||
|
$11, $12, $13, $14, $15::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (report_id) DO NOTHING
|
||||||
|
RETURNING id, report_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FIND_REPORT_SQL = `
|
||||||
|
SELECT id, report_id
|
||||||
|
FROM public.yuntu_report_info
|
||||||
|
WHERE report_id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createRepository(pool) {
|
||||||
|
return {
|
||||||
|
async save(record) {
|
||||||
|
const insertValues = [
|
||||||
|
record.report_id,
|
||||||
|
record.source_type,
|
||||||
|
record.source_report_id,
|
||||||
|
record.aadvid,
|
||||||
|
record.name,
|
||||||
|
JSON.stringify(record.price),
|
||||||
|
JSON.stringify(record.rules),
|
||||||
|
JSON.stringify(record.analysis_dims),
|
||||||
|
JSON.stringify(record.categories),
|
||||||
|
JSON.stringify(record.transaction_channels),
|
||||||
|
record.start_time,
|
||||||
|
record.end_time,
|
||||||
|
record.period_type,
|
||||||
|
record.user_name,
|
||||||
|
JSON.stringify(record.payload),
|
||||||
|
];
|
||||||
|
|
||||||
|
const insertResult = await pool.query(INSERT_REPORT_SQL, insertValues);
|
||||||
|
|
||||||
|
if (insertResult.rows.length > 0) {
|
||||||
|
const row = insertResult.rows[0];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reportId: row.report_id,
|
||||||
|
created: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingResult = await pool.query(FIND_REPORT_SQL, [record.report_id]);
|
||||||
|
const existingRow = existingResult.rows[0];
|
||||||
|
|
||||||
|
if (!existingRow) {
|
||||||
|
throw new Error(`report_id ${record.report_id} was not found after conflict`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existingRow.id,
|
||||||
|
reportId: existingRow.report_id,
|
||||||
|
created: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRepository,
|
||||||
|
FIND_REPORT_SQL,
|
||||||
|
INSERT_REPORT_SQL,
|
||||||
|
};
|
||||||
115
yuntu/yuntuReportFilling/server/src/report-service.js
Normal file
115
yuntu/yuntuReportFilling/server/src/report-service.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
const ALLOWED_SOURCE_TYPES = new Set(['MANUAL_CAPTURE', 'AUTO_COPY']);
|
||||||
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
class ValidationError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
this.code = 'VALIDATION_ERROR';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNonEmptyString(value, fieldName) {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
throw new ValidationError(`${fieldName} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOptionalString(value, fieldName) {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureNonEmptyString(value, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureArray(value, fieldName) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new ValidationError(`${fieldName} must be an array`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureObject(value, fieldName) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new ValidationError(`${fieldName} must be an object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDateString(value, fieldName) {
|
||||||
|
const normalized = ensureNonEmptyString(value, fieldName);
|
||||||
|
|
||||||
|
if (!DATE_PATTERN.test(normalized)) {
|
||||||
|
throw new ValidationError(`${fieldName} must match YYYY-MM-DD`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndNormalizeReportInput(input) {
|
||||||
|
const payload = ensureObject(input, 'body');
|
||||||
|
const sourceType = ensureNonEmptyString(payload.sourceType, 'sourceType');
|
||||||
|
|
||||||
|
if (!ALLOWED_SOURCE_TYPES.has(sourceType)) {
|
||||||
|
throw new ValidationError('sourceType must be MANUAL_CAPTURE or AUTO_COPY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceReportId = ensureOptionalString(payload.sourceReportId, 'sourceReportId');
|
||||||
|
|
||||||
|
if (sourceType === 'MANUAL_CAPTURE' && sourceReportId !== null) {
|
||||||
|
throw new ValidationError('sourceReportId must be empty for MANUAL_CAPTURE');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceType === 'AUTO_COPY' && sourceReportId === null) {
|
||||||
|
throw new ValidationError('sourceReportId is required when sourceType is AUTO_COPY');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportId: ensureNonEmptyString(payload.reportId, 'reportId'),
|
||||||
|
sourceType,
|
||||||
|
sourceReportId,
|
||||||
|
aadvid: ensureNonEmptyString(payload.aadvid, 'aadvid'),
|
||||||
|
name: ensureNonEmptyString(payload.name, 'name'),
|
||||||
|
price: ensureArray(payload.price, 'price'),
|
||||||
|
rules: ensureArray(payload.rules, 'rules'),
|
||||||
|
analysisDims: ensureArray(payload.analysisDims, 'analysisDims'),
|
||||||
|
categories: ensureArray(payload.categories, 'categories'),
|
||||||
|
channels: ensureArray(payload.channels, 'channels'),
|
||||||
|
startTime: ensureDateString(payload.startTime, 'startTime'),
|
||||||
|
endTime: ensureDateString(payload.endTime, 'endTime'),
|
||||||
|
periodType: ensureOptionalString(payload.periodType, 'periodType'),
|
||||||
|
userName: ensureOptionalString(payload.userName, 'userName'),
|
||||||
|
payload: ensureObject(payload.payload, 'payload'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDatabaseRecord(normalizedReport) {
|
||||||
|
return {
|
||||||
|
report_id: normalizedReport.reportId,
|
||||||
|
source_type: normalizedReport.sourceType,
|
||||||
|
source_report_id: normalizedReport.sourceReportId,
|
||||||
|
aadvid: normalizedReport.aadvid,
|
||||||
|
name: normalizedReport.name,
|
||||||
|
price: normalizedReport.price,
|
||||||
|
rules: normalizedReport.rules,
|
||||||
|
analysis_dims: normalizedReport.analysisDims,
|
||||||
|
categories: normalizedReport.categories,
|
||||||
|
transaction_channels: normalizedReport.channels,
|
||||||
|
start_time: normalizedReport.startTime,
|
||||||
|
end_time: normalizedReport.endTime,
|
||||||
|
period_type: normalizedReport.periodType,
|
||||||
|
user_name: normalizedReport.userName,
|
||||||
|
payload: normalizedReport.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ValidationError,
|
||||||
|
validateAndNormalizeReportInput,
|
||||||
|
toDatabaseRecord,
|
||||||
|
};
|
||||||
70
yuntu/yuntuReportFilling/server/src/server.js
Normal file
70
yuntu/yuntuReportFilling/server/src/server.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
const cors = require('cors');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const {
|
||||||
|
ValidationError,
|
||||||
|
toDatabaseRecord,
|
||||||
|
validateAndNormalizeReportInput,
|
||||||
|
} = require('./report-service');
|
||||||
|
|
||||||
|
function createApp({ repository, allowedOrigin }) {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: allowedOrigin,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
app.get('/health', (_request, response) => {
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
status: 'ok',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/reports', async (request, response) => {
|
||||||
|
try {
|
||||||
|
const normalizedReport = validateAndNormalizeReportInput(request.body);
|
||||||
|
const record = toDatabaseRecord(normalizedReport);
|
||||||
|
const saved = await repository.save(record);
|
||||||
|
|
||||||
|
response.status(saved.created ? 201 : 200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: saved.id,
|
||||||
|
reportId: saved.reportId,
|
||||||
|
created: saved.created,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: error.message || 'Unexpected server error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createApp,
|
||||||
|
};
|
||||||
95
yuntu/yuntuReportFilling/server/test/report-service.test.js
Normal file
95
yuntu/yuntuReportFilling/server/test/report-service.test.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
validateAndNormalizeReportInput,
|
||||||
|
toDatabaseRecord,
|
||||||
|
} = require('../src/report-service');
|
||||||
|
|
||||||
|
function createManualPayload() {
|
||||||
|
return {
|
||||||
|
reportId: 'report-001',
|
||||||
|
sourceType: 'MANUAL_CAPTURE',
|
||||||
|
sourceReportId: null,
|
||||||
|
aadvid: '1648829117232140',
|
||||||
|
name: '测试报告',
|
||||||
|
price: ['1,100', '101,100000'],
|
||||||
|
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
|
||||||
|
analysisDims: ['MARKETOVERVIEW'],
|
||||||
|
categories: [{ id: '20028', name: '奶粉类目' }],
|
||||||
|
channels: ['ALL'],
|
||||||
|
startTime: '2025-03-01',
|
||||||
|
endTime: '2026-02-28',
|
||||||
|
periodType: 'MONTH',
|
||||||
|
userName: 'tester@example.com',
|
||||||
|
payload: {
|
||||||
|
name: '测试报告',
|
||||||
|
startTime: '2025-03-01',
|
||||||
|
endTime: '2026-02-28',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('validateAndNormalizeReportInput accepts a valid manual report payload', () => {
|
||||||
|
const input = createManualPayload();
|
||||||
|
|
||||||
|
const result = validateAndNormalizeReportInput(input);
|
||||||
|
|
||||||
|
assert.equal(result.reportId, 'report-001');
|
||||||
|
assert.equal(result.sourceType, 'MANUAL_CAPTURE');
|
||||||
|
assert.equal(result.sourceReportId, null);
|
||||||
|
assert.equal(result.aadvid, '1648829117232140');
|
||||||
|
assert.equal(result.startTime, '2025-03-01');
|
||||||
|
assert.equal(result.endTime, '2026-02-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateAndNormalizeReportInput rejects AUTO_COPY without sourceReportId', () => {
|
||||||
|
const input = {
|
||||||
|
...createManualPayload(),
|
||||||
|
reportId: 'report-002',
|
||||||
|
sourceType: 'AUTO_COPY',
|
||||||
|
sourceReportId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => validateAndNormalizeReportInput(input),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.code, 'VALIDATION_ERROR');
|
||||||
|
assert.match(error.message, /sourceReportId/i);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toDatabaseRecord maps API payload fields into database-ready values', () => {
|
||||||
|
const normalized = validateAndNormalizeReportInput({
|
||||||
|
...createManualPayload(),
|
||||||
|
reportId: 'report-003',
|
||||||
|
sourceType: 'AUTO_COPY',
|
||||||
|
sourceReportId: 'report-001',
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = toDatabaseRecord(normalized);
|
||||||
|
|
||||||
|
assert.deepEqual(record, {
|
||||||
|
report_id: 'report-003',
|
||||||
|
source_type: 'AUTO_COPY',
|
||||||
|
source_report_id: 'report-001',
|
||||||
|
aadvid: '1648829117232140',
|
||||||
|
name: '测试报告',
|
||||||
|
price: ['1,100', '101,100000'],
|
||||||
|
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
|
||||||
|
analysis_dims: ['MARKETOVERVIEW'],
|
||||||
|
categories: [{ id: '20028', name: '奶粉类目' }],
|
||||||
|
transaction_channels: ['ALL'],
|
||||||
|
start_time: '2025-03-01',
|
||||||
|
end_time: '2026-02-28',
|
||||||
|
period_type: 'MONTH',
|
||||||
|
user_name: 'tester@example.com',
|
||||||
|
payload: {
|
||||||
|
name: '测试报告',
|
||||||
|
startTime: '2025-03-01',
|
||||||
|
endTime: '2026-02-28',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
146
yuntu/yuntuReportFilling/server/test/server.test.js
Normal file
146
yuntu/yuntuReportFilling/server/test/server.test.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { createApp } = require('../src/server');
|
||||||
|
|
||||||
|
function createManualPayload() {
|
||||||
|
return {
|
||||||
|
reportId: 'report-001',
|
||||||
|
sourceType: 'MANUAL_CAPTURE',
|
||||||
|
sourceReportId: null,
|
||||||
|
aadvid: '1648829117232140',
|
||||||
|
name: '测试报告',
|
||||||
|
price: ['1,100', '101,100000'],
|
||||||
|
rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }],
|
||||||
|
analysisDims: ['MARKETOVERVIEW'],
|
||||||
|
categories: [{ id: '20028', name: '奶粉类目' }],
|
||||||
|
channels: ['ALL'],
|
||||||
|
startTime: '2025-03-01',
|
||||||
|
endTime: '2026-02-28',
|
||||||
|
periodType: 'MONTH',
|
||||||
|
userName: 'tester@example.com',
|
||||||
|
payload: {
|
||||||
|
name: '测试报告',
|
||||||
|
startTime: '2025-03-01',
|
||||||
|
endTime: '2026-02-28',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withServer(repository, callback) {
|
||||||
|
const app = createApp({
|
||||||
|
repository,
|
||||||
|
allowedOrigin: 'https://yuntu.oceanengine.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = await new Promise((resolve) => {
|
||||||
|
const instance = app.listen(0, '127.0.0.1', () => resolve(instance));
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await callback(baseUrl);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET /health returns ok status', async () => {
|
||||||
|
await withServer(
|
||||||
|
{
|
||||||
|
async save() {
|
||||||
|
throw new Error('save should not be called');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (baseUrl) => {
|
||||||
|
const response = await fetch(`${baseUrl}/health`);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(body, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
status: 'ok',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/reports returns 400 for invalid auto copy payload', async () => {
|
||||||
|
await withServer(
|
||||||
|
{
|
||||||
|
async save() {
|
||||||
|
throw new Error('save should not be called');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (baseUrl) => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...createManualPayload(),
|
||||||
|
sourceType: 'AUTO_COPY',
|
||||||
|
sourceReportId: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
assert.equal(body.success, false);
|
||||||
|
assert.equal(body.error.code, 'VALIDATION_ERROR');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/reports persists a valid payload and returns created response', async () => {
|
||||||
|
let savedRecord = null;
|
||||||
|
|
||||||
|
await withServer(
|
||||||
|
{
|
||||||
|
async save(record) {
|
||||||
|
savedRecord = record;
|
||||||
|
return {
|
||||||
|
id: 12,
|
||||||
|
reportId: record.report_id,
|
||||||
|
created: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (baseUrl) => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(createManualPayload()),
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
assert.equal(response.status, 201);
|
||||||
|
assert.deepEqual(body, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: 12,
|
||||||
|
reportId: 'report-001',
|
||||||
|
created: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(savedRecord.report_id, 'report-001');
|
||||||
|
assert.equal(savedRecord.source_type, 'MANUAL_CAPTURE');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
134
yuntu/yuntuReportFilling/yuntuReportFilling.test.js
Normal file
134
yuntu/yuntuReportFilling/yuntuReportFilling.test.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const api = require("./yuntuReportFilling.user.js");
|
||||||
|
|
||||||
|
test("isTargetCreateReportRequest matches the segmented market create endpoint", () => {
|
||||||
|
assert.equal(
|
||||||
|
api.isTargetCreateReportRequest(
|
||||||
|
"https://yuntu.oceanengine.com/product_node/v2/api/segmentedMarket/createSegmentedMarket?aadvid=1648829117232140",
|
||||||
|
"POST",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
api.isTargetCreateReportRequest(
|
||||||
|
"https://yuntu.oceanengine.com/product_node/v2/api/industry/insightBrandStats?aadvid=1648829117232140",
|
||||||
|
"POST",
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isSupportedPageUrl covers both creation and detail pages", () => {
|
||||||
|
assert.equal(
|
||||||
|
api.isSupportedPageUrl(
|
||||||
|
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation?aadvid=1648829117232140",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
api.isSupportedPageUrl(
|
||||||
|
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/marketOverview?aadvid=1710507483282439&reportId=441518",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
api.isSupportedPageUrl(
|
||||||
|
"https://yuntu.oceanengine.com/yuntu_brand/ecom/product/otherPage?aadvid=1710507483282439",
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getLocalApiBaseCandidates prefers localhost and falls back to 127.0.0.1", () => {
|
||||||
|
assert.deepEqual(api.getLocalApiBaseCandidates(), [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extractReportId returns response.data.reportId when present", () => {
|
||||||
|
assert.equal(
|
||||||
|
api.extractReportId({
|
||||||
|
data: {
|
||||||
|
reportId: "987654321",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"987654321",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(api.extractReportId({ data: {} }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shiftDateBackOneYear keeps month/day and clamps leap day", () => {
|
||||||
|
assert.equal(api.shiftDateBackOneYear("2025-03-01"), "2024-03-01");
|
||||||
|
assert.equal(api.shiftDateBackOneYear("2024-02-29"), "2023-02-28");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildAutoCopyPayload deep clones payload, shifts top-level dates, and appends the year range to name", () => {
|
||||||
|
const original = {
|
||||||
|
name: "测试(近一年)",
|
||||||
|
startTime: "2025-03-01",
|
||||||
|
endTime: "2026-02-28",
|
||||||
|
nested: {
|
||||||
|
startTime: "SHOULD_NOT_CHANGE",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const copied = api.buildAutoCopyPayload(original);
|
||||||
|
|
||||||
|
assert.deepEqual(copied, {
|
||||||
|
name: "测试(近一年)2024-2025",
|
||||||
|
startTime: "2024-03-01",
|
||||||
|
endTime: "2025-02-28",
|
||||||
|
nested: {
|
||||||
|
startTime: "SHOULD_NOT_CHANGE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.notEqual(copied, original);
|
||||||
|
assert.notEqual(copied.nested, original.nested);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPersistRequest maps manual capture payload into backend request format", () => {
|
||||||
|
const payload = {
|
||||||
|
name: "测试",
|
||||||
|
price: ["1,100", "101,100000"],
|
||||||
|
rules: [{ keywords: ["奶粉"], op: "INCLUDE" }],
|
||||||
|
analysisDims: ["MARKETOVERVIEW"],
|
||||||
|
categories: [{ id: "20028" }],
|
||||||
|
channels: ["ALL"],
|
||||||
|
startTime: "2025-03-01",
|
||||||
|
endTime: "2026-02-28",
|
||||||
|
periodType: "MONTH",
|
||||||
|
userName: "tester@example.com",
|
||||||
|
};
|
||||||
|
const request = api.buildPersistRequest({
|
||||||
|
payload,
|
||||||
|
aadvid: "1648829117232140",
|
||||||
|
reportId: "report-001",
|
||||||
|
sourceType: "MANUAL_CAPTURE",
|
||||||
|
sourceReportId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
reportId: "report-001",
|
||||||
|
sourceType: "MANUAL_CAPTURE",
|
||||||
|
sourceReportId: null,
|
||||||
|
aadvid: "1648829117232140",
|
||||||
|
name: "测试",
|
||||||
|
price: ["1,100", "101,100000"],
|
||||||
|
rules: [{ keywords: ["奶粉"], op: "INCLUDE" }],
|
||||||
|
analysisDims: ["MARKETOVERVIEW"],
|
||||||
|
categories: [{ id: "20028" }],
|
||||||
|
channels: ["ALL"],
|
||||||
|
startTime: "2025-03-01",
|
||||||
|
endTime: "2026-02-28",
|
||||||
|
periodType: "MONTH",
|
||||||
|
userName: "tester@example.com",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
});
|
||||||
716
yuntu/yuntuReportFilling/yuntuReportFilling.user.js
Normal file
716
yuntu/yuntuReportFilling/yuntuReportFilling.user.js
Normal file
@ -0,0 +1,716 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name 云图报告同比复制助手
|
||||||
|
// @namespace https://yuntu.oceanengine.com/
|
||||||
|
// @version 0.1.0
|
||||||
|
// @description 记录最近一次成功创建的云图报告,并一键复制同比报告
|
||||||
|
// @author wangxi
|
||||||
|
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation*
|
||||||
|
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/*
|
||||||
|
// @grant GM_getValue
|
||||||
|
// @grant GM_setValue
|
||||||
|
// @grant GM_xmlhttpRequest
|
||||||
|
// @grant GM_addStyle
|
||||||
|
// @grant unsafeWindow
|
||||||
|
// @connect localhost
|
||||||
|
// @connect 127.0.0.1
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(function bootstrap(root, factory) {
|
||||||
|
const api = factory(root);
|
||||||
|
if (typeof module === "object" && module.exports) {
|
||||||
|
module.exports = api;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.init();
|
||||||
|
})(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) {
|
||||||
|
const TARGET_PATH = "/product_node/v2/api/segmentedMarket/createSegmentedMarket";
|
||||||
|
const LOCAL_API_BASES = ["http://localhost:3000", "http://127.0.0.1:3000"];
|
||||||
|
const SUPPORTED_PAGE_PATTERNS = [
|
||||||
|
/^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/,
|
||||||
|
/^\/yuntu_brand\/ecom\/product\/segmentedMarketDetail\/.*$/,
|
||||||
|
];
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
payload: "lastReportPayload",
|
||||||
|
meta: "lastReportMeta",
|
||||||
|
};
|
||||||
|
const EVENT_NAME = "yuntu-report-filling:capture";
|
||||||
|
const BUTTON_ID = "yuntu-report-filling-action";
|
||||||
|
const STYLE_ID = "yuntu-report-filling-style";
|
||||||
|
const INJECT_FLAG = "__yuntuReportFillingHooked__";
|
||||||
|
|
||||||
|
const runtimeState = {
|
||||||
|
isSubmitting: false,
|
||||||
|
suppressedRequestBody: null,
|
||||||
|
clearSuppressionTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(message, extra) {
|
||||||
|
if (extra === undefined) {
|
||||||
|
console.log("[YuntuReportFilling]", message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[YuntuReportFilling]", message, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTargetCreateReportRequest(url, method) {
|
||||||
|
if (String(method || "").toUpperCase() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, root.location && root.location.href ? root.location.href : "https://yuntu.oceanengine.com");
|
||||||
|
return parsed.pathname === TARGET_PATH && parsed.searchParams.has("aadvid");
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedPageUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(
|
||||||
|
url,
|
||||||
|
root.location && root.location.href
|
||||||
|
? root.location.href
|
||||||
|
: "https://yuntu.oceanengine.com",
|
||||||
|
);
|
||||||
|
return SUPPORTED_PAGE_PATTERNS.some((pattern) =>
|
||||||
|
pattern.test(parsed.pathname),
|
||||||
|
);
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalApiBaseCandidates() {
|
||||||
|
return [...LOCAL_API_BASES];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractReportId(responseJson) {
|
||||||
|
if (!responseJson || typeof responseJson !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportId = responseJson.data && responseJson.data.reportId;
|
||||||
|
if (reportId === null || reportId === undefined || reportId === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDateBackOneYear(dateString) {
|
||||||
|
const match = String(dateString).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid date string: ${dateString}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const targetYear = year - 1;
|
||||||
|
const lastDay = new Date(Date.UTC(targetYear, month, 0)).getUTCDate();
|
||||||
|
const safeDay = Math.min(day, lastDay);
|
||||||
|
|
||||||
|
return `${targetYear}-${String(month).padStart(2, "0")}-${String(safeDay).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepCloneJson(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYear(dateString) {
|
||||||
|
return String(dateString).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAutoCopyName(originalName, startTime, endTime) {
|
||||||
|
const baseName = originalName || "";
|
||||||
|
return `${baseName}${extractYear(startTime)}-${extractYear(endTime)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAutoCopyPayload(payload) {
|
||||||
|
const cloned = deepCloneJson(payload);
|
||||||
|
cloned.startTime = shiftDateBackOneYear(payload.startTime);
|
||||||
|
cloned.endTime = shiftDateBackOneYear(payload.endTime);
|
||||||
|
cloned.name = buildAutoCopyName(payload.name, cloned.startTime, cloned.endTime);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPersistRequest({
|
||||||
|
payload,
|
||||||
|
aadvid,
|
||||||
|
reportId,
|
||||||
|
sourceType,
|
||||||
|
sourceReportId,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
reportId: String(reportId),
|
||||||
|
sourceType,
|
||||||
|
sourceReportId: sourceReportId ? String(sourceReportId) : null,
|
||||||
|
aadvid: String(aadvid),
|
||||||
|
name: payload.name || "",
|
||||||
|
price: Array.isArray(payload.price) ? payload.price : [],
|
||||||
|
rules: Array.isArray(payload.rules) ? payload.rules : [],
|
||||||
|
analysisDims: Array.isArray(payload.analysisDims) ? payload.analysisDims : [],
|
||||||
|
categories: Array.isArray(payload.categories) ? payload.categories : [],
|
||||||
|
channels: Array.isArray(payload.channels) ? payload.channels : [],
|
||||||
|
startTime: payload.startTime || "",
|
||||||
|
endTime: payload.endTime || "",
|
||||||
|
periodType: payload.periodType || null,
|
||||||
|
userName: payload.userName || null,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(text) {
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAadvidFromUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, root.location.href);
|
||||||
|
return parsed.searchParams.get("aadvid");
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreateRequestUrl(meta) {
|
||||||
|
if (meta && meta.requestUrl) {
|
||||||
|
return meta.requestUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aadvid = (meta && meta.aadvid) || getAadvidFromUrl(root.location.href);
|
||||||
|
if (!aadvid) {
|
||||||
|
throw new Error("未获取到 aadvid");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://yuntu.oceanengine.com${TARGET_PATH}?aadvid=${encodeURIComponent(aadvid)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gmGetValueSafe(key, fallbackValue) {
|
||||||
|
if (typeof GM_getValue !== "function") {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
return GM_getValue(key, fallbackValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gmSetValueSafe(key, value) {
|
||||||
|
if (typeof GM_setValue !== "function") {
|
||||||
|
throw new Error("GM_setValue 不可用");
|
||||||
|
}
|
||||||
|
GM_setValue(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredPayload() {
|
||||||
|
const rawValue = gmGetValueSafe(STORAGE_KEYS.payload, "");
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseJson(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredMeta() {
|
||||||
|
const rawValue = gmGetValueSafe(STORAGE_KEYS.meta, "");
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseJson(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredData(payload, meta) {
|
||||||
|
gmSetValueSafe(STORAGE_KEYS.payload, JSON.stringify(payload));
|
||||||
|
gmSetValueSafe(STORAGE_KEYS.meta, JSON.stringify(meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSuppression() {
|
||||||
|
runtimeState.suppressedRequestBody = null;
|
||||||
|
if (runtimeState.clearSuppressionTimer) {
|
||||||
|
root.clearTimeout(runtimeState.clearSuppressionTimer);
|
||||||
|
runtimeState.clearSuppressionTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function suppressNextCapture(requestBodyText) {
|
||||||
|
runtimeState.suppressedRequestBody = requestBodyText;
|
||||||
|
if (runtimeState.clearSuppressionTimer) {
|
||||||
|
root.clearTimeout(runtimeState.clearSuppressionTimer);
|
||||||
|
}
|
||||||
|
runtimeState.clearSuppressionTimer = root.setTimeout(() => {
|
||||||
|
clearSuppression();
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type) {
|
||||||
|
const toast = root.document.createElement("div");
|
||||||
|
toast.className = `yuntu-report-filling-toast is-${type || "info"}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
root.document.body.appendChild(toast);
|
||||||
|
|
||||||
|
root.requestAnimationFrame(() => {
|
||||||
|
toast.classList.add("is-visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
root.setTimeout(() => {
|
||||||
|
toast.classList.remove("is-visible");
|
||||||
|
root.setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 240);
|
||||||
|
}, 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtonState() {
|
||||||
|
const button = root.document.getElementById(BUTTON_ID);
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPayload = Boolean(readStoredPayload());
|
||||||
|
button.disabled = runtimeState.isSubmitting || !hasPayload;
|
||||||
|
button.textContent = runtimeState.isSubmitting
|
||||||
|
? "创建中..."
|
||||||
|
: "一键复制同比报告";
|
||||||
|
button.dataset.enabled = String(hasPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistToLocalServer(payload) {
|
||||||
|
const requestBody = JSON.stringify(payload);
|
||||||
|
const bases = getLocalApiBaseCandidates();
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (const base of bases) {
|
||||||
|
try {
|
||||||
|
if (typeof GM_xmlhttpRequest !== "function") {
|
||||||
|
const response = await fetch(`${base}/api/reports`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error((data.error && data.error.message) || "本地服务请求失败");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await new Promise((resolve, reject) => {
|
||||||
|
GM_xmlhttpRequest({
|
||||||
|
method: "POST",
|
||||||
|
url: `${base}/api/reports`,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: requestBody,
|
||||||
|
onload(response) {
|
||||||
|
try {
|
||||||
|
const json = parseJson(response.responseText);
|
||||||
|
if (response.status < 200 || response.status >= 300 || !json.success) {
|
||||||
|
reject(new Error((json.error && json.error.message) || "本地服务请求失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(json);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onerror() {
|
||||||
|
reject(new Error(`无法连接本地服务: ${base}`));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`本地服务未启动,请先在 server 目录执行 npm start。${lastError ? ` 原始错误: ${lastError.message}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createReportThroughPage(url, payload) {
|
||||||
|
const pageFetch =
|
||||||
|
typeof unsafeWindow !== "undefined" && unsafeWindow.fetch
|
||||||
|
? unsafeWindow.fetch.bind(unsafeWindow)
|
||||||
|
: root.fetch.bind(root);
|
||||||
|
|
||||||
|
const requestBodyText = JSON.stringify(payload);
|
||||||
|
suppressNextCapture(requestBodyText);
|
||||||
|
|
||||||
|
const response = await pageFetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json, text/plain, */*",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: requestBodyText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
clearSuppression();
|
||||||
|
throw new Error((data && data.message) || `创建报告失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
responseJson: data,
|
||||||
|
requestBodyText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleButtonClick() {
|
||||||
|
if (runtimeState.isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = readStoredPayload();
|
||||||
|
const meta = readStoredMeta();
|
||||||
|
|
||||||
|
if (!payload || !meta) {
|
||||||
|
updateButtonState();
|
||||||
|
showToast("请先手动成功创建一次报告", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeState.isSubmitting = true;
|
||||||
|
updateButtonState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPayload = buildAutoCopyPayload(payload);
|
||||||
|
const requestUrl = getCreateRequestUrl(meta);
|
||||||
|
const sourceReportId = meta.reportId || null;
|
||||||
|
const { responseJson } = await createReportThroughPage(requestUrl, nextPayload);
|
||||||
|
const reportId = extractReportId(responseJson);
|
||||||
|
|
||||||
|
if (!reportId) {
|
||||||
|
throw new Error("创建成功,但未获取到 reportId");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMeta = {
|
||||||
|
reportId,
|
||||||
|
aadvid: meta.aadvid || getAadvidFromUrl(requestUrl),
|
||||||
|
sourceType: "AUTO_COPY",
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
requestUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeStoredData(nextPayload, nextMeta);
|
||||||
|
await persistToLocalServer(
|
||||||
|
buildPersistRequest({
|
||||||
|
payload: nextPayload,
|
||||||
|
aadvid: nextMeta.aadvid,
|
||||||
|
reportId,
|
||||||
|
sourceType: "AUTO_COPY",
|
||||||
|
sourceReportId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
showToast(`同比报告创建成功:${reportId}`, "success");
|
||||||
|
} catch (error) {
|
||||||
|
log("自动创建同比报告失败", error);
|
||||||
|
showToast(error.message || "同比报告创建失败", "error");
|
||||||
|
} finally {
|
||||||
|
runtimeState.isSubmitting = false;
|
||||||
|
updateButtonState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCapturedRequest(detail) {
|
||||||
|
if (!detail || !detail.requestBody || !detail.responseText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeState.suppressedRequestBody && runtimeState.suppressedRequestBody === detail.requestBody) {
|
||||||
|
clearSuppression();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = parseJson(detail.requestBody);
|
||||||
|
const responseJson = parseJson(detail.responseText);
|
||||||
|
const reportId = extractReportId(responseJson);
|
||||||
|
if (!reportId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aadvid = getAadvidFromUrl(detail.url) || getAadvidFromUrl(root.location.href);
|
||||||
|
if (!aadvid) {
|
||||||
|
throw new Error("未从请求中识别到 aadvid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
reportId,
|
||||||
|
aadvid,
|
||||||
|
sourceType: "MANUAL_CAPTURE",
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
requestUrl: detail.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeStoredData(payload, meta);
|
||||||
|
updateButtonState();
|
||||||
|
|
||||||
|
await persistToLocalServer(
|
||||||
|
buildPersistRequest({
|
||||||
|
payload,
|
||||||
|
aadvid,
|
||||||
|
reportId,
|
||||||
|
sourceType: "MANUAL_CAPTURE",
|
||||||
|
sourceReportId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
showToast(`已记录报告配置:${reportId}`, "success");
|
||||||
|
} catch (error) {
|
||||||
|
log("记录手动创建报告失败", error);
|
||||||
|
showToast(error.message || "记录报告配置失败", "error");
|
||||||
|
updateButtonState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStyles() {
|
||||||
|
if (root.document.getElementById(STYLE_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = `
|
||||||
|
#${BUTTON_ID} {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 99999;
|
||||||
|
width: 148px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${BUTTON_ID}:hover:not(:disabled) {
|
||||||
|
transform: translateY(-50%) translateX(-2px);
|
||||||
|
box-shadow: 0 14px 32px rgba(15, 118, 110, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
#${BUTTON_ID}:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.52;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yuntu-report-filling-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 100000;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yuntu-report-filling-toast.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yuntu-report-filling-toast.is-success {
|
||||||
|
background: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yuntu-report-filling-toast.is-error {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yuntu-report-filling-toast.is-info {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (typeof GM_addStyle === "function") {
|
||||||
|
GM_addStyle(css);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = root.document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = css;
|
||||||
|
root.document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureButton() {
|
||||||
|
if (root.document.getElementById(BUTTON_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = root.document.createElement("button");
|
||||||
|
button.id = BUTTON_ID;
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "一键复制同比报告";
|
||||||
|
button.addEventListener("click", handleButtonClick);
|
||||||
|
root.document.body.appendChild(button);
|
||||||
|
updateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectCaptureHook() {
|
||||||
|
if (!root.document || root.document.documentElement.getAttribute(INJECT_FLAG) === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = root.document.createElement("script");
|
||||||
|
script.textContent = `
|
||||||
|
(() => {
|
||||||
|
if (window.${INJECT_FLAG}) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.${INJECT_FLAG} = true;
|
||||||
|
|
||||||
|
const eventName = ${JSON.stringify(EVENT_NAME)};
|
||||||
|
const targetPath = ${JSON.stringify(TARGET_PATH)};
|
||||||
|
|
||||||
|
const isTarget = (url, method) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.href);
|
||||||
|
return String(method || "").toUpperCase() === "POST"
|
||||||
|
&& parsed.pathname === targetPath
|
||||||
|
&& parsed.searchParams.has("aadvid");
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeBody = (body) => {
|
||||||
|
if (typeof body === "string") {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
if (!body) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (body instanceof URLSearchParams) {
|
||||||
|
return body.toString();
|
||||||
|
}
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(body);
|
||||||
|
} catch (_error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchCapture = (detail) => {
|
||||||
|
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = async function patchedFetch(input, init) {
|
||||||
|
const requestUrl = input instanceof Request ? input.url : String(input);
|
||||||
|
const method = (init && init.method) || (input instanceof Request ? input.method : "GET");
|
||||||
|
const requestBody = init && "body" in init ? serializeBody(init.body) : "";
|
||||||
|
const response = await originalFetch.apply(this, arguments);
|
||||||
|
|
||||||
|
if (isTarget(requestUrl, method)) {
|
||||||
|
try {
|
||||||
|
const cloned = response.clone();
|
||||||
|
const responseText = await cloned.text();
|
||||||
|
dispatchCapture({
|
||||||
|
url: requestUrl,
|
||||||
|
method,
|
||||||
|
requestBody,
|
||||||
|
responseText,
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
} catch (_error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalOpen = XMLHttpRequest.prototype.open;
|
||||||
|
const originalSend = XMLHttpRequest.prototype.send;
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
|
||||||
|
this.__yuntuReportMethod = method;
|
||||||
|
this.__yuntuReportUrl = url;
|
||||||
|
return originalOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.send = function patchedSend(body) {
|
||||||
|
this.__yuntuReportBody = serializeBody(body);
|
||||||
|
this.addEventListener("loadend", () => {
|
||||||
|
if (!isTarget(this.__yuntuReportUrl, this.__yuntuReportMethod)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatchCapture({
|
||||||
|
url: this.__yuntuReportUrl,
|
||||||
|
method: this.__yuntuReportMethod,
|
||||||
|
requestBody: this.__yuntuReportBody || "",
|
||||||
|
responseText: typeof this.responseText === "string" ? this.responseText : "",
|
||||||
|
status: this.status,
|
||||||
|
});
|
||||||
|
}, { once: true });
|
||||||
|
return originalSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
root.document.documentElement.appendChild(script);
|
||||||
|
script.remove();
|
||||||
|
root.document.documentElement.setAttribute(INJECT_FLAG, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachCaptureListener() {
|
||||||
|
root.addEventListener(EVENT_NAME, (event) => {
|
||||||
|
handleCapturedRequest(event.detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!isSupportedPageUrl(root.location && root.location.href ? root.location.href : "")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!root.document || !root.document.body) {
|
||||||
|
root.addEventListener("DOMContentLoaded", init, { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureStyles();
|
||||||
|
ensureButton();
|
||||||
|
attachCaptureListener();
|
||||||
|
injectCaptureHook();
|
||||||
|
updateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildAutoCopyPayload,
|
||||||
|
buildAutoCopyName,
|
||||||
|
buildPersistRequest,
|
||||||
|
extractReportId,
|
||||||
|
getLocalApiBaseCandidates,
|
||||||
|
init,
|
||||||
|
isSupportedPageUrl,
|
||||||
|
isTargetCreateReportRequest,
|
||||||
|
shiftDateBackOneYear,
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user