在前后端开发联调前 的提交20260223

This commit is contained in:
Neo
2026-02-23 23:02:20 +08:00
parent 254ccb1e77
commit fafc95e64c
1142 changed files with 10366960 additions and 36957 deletions

View File

@@ -0,0 +1,52 @@
# BD_Manualassistant_cancellation_records助教废除记录
> ODS 表:`ods.assistant_cancellation_records`
> DWD 表:`dwd.dwd_assistant_trash_event`(主表)、`dwd.dwd_assistant_trash_event_ex`(扩展表)
> API 接口:助教废除记录列表
> JSON 路径:`assistant_cancellation_records.json → data.orderAssistantTrashLedgers`
> 装载方式:事实表增量插入(`DwdLoadTask`
> 代码位置:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
---
## 1. dwd_assistant_trash_event主表
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|----------|------|---------|---------|---------|-------------|
| `assistant_trash_event_id` | BIGINT | `id` | FACT_MAPPINGS | 废除记录唯一标识PK | 飞球雪花 ID |
| `site_id` | BIGINT | `siteid` | FACT_MAPPINGS | 门店 ID | 飞球门店 ID |
| `tenant_id` | BIGINT | `tenant_id` | FACT_MAPPINGS | 租户 ID | 飞球租户 ID |
| `assistant_no` | TEXT | `assistanton` | FACT_MAPPINGS | 助教编号(工号/序号。ODS 列名 `assistantOn` 在 PG 中小写化为 `assistanton`,语义为助教的门店内编号 | 如 `31``1` |
| `abolish_amount` | NUMERIC | `assistantabolishamount` | FACT_MAPPINGS | 废除金额(元),被废除的服务应收金额 | `0.00` ~ 金额值 |
| `charge_minutes_raw` | INTEGER | `pdchargeminutes` | FACT_MAPPINGS | 陪打计费时长(分钟),被废除的服务时长 | 正整数 |
| `table_id` | BIGINT | `tableid` | FACT_MAPPINGS | 台桌 ID | 飞球台桌 ID |
| `table_area_id` | BIGINT | `tableareaid` | FACT_MAPPINGS | 台桌区域 ID | 飞球区域 ID |
| `assistant_name` | TEXT | `assistantname` | FACT_MAPPINGS | 助教姓名快照 | 如 `张静然` |
| `trash_reason` | TEXT | `trashreason` | FACT_MAPPINGS | 废除原因 | 自由文本 |
| `create_time` | TIMESTAMPTZ | `createtime` | FACT_MAPPINGS | 废除操作时间 | ISO 时间戳 |
---
## 2. dwd_assistant_trash_event_ex扩展表
| DWD 列名 | 类型 | ODS 源列 | 映射方式 | 业务含义 | 取值范围/示例 |
|----------|------|---------|---------|---------|-------------|
| `assistant_trash_event_id` | BIGINT | `id` | FACT_MAPPINGS | 废除记录唯一标识PK | 同主表 |
| `table_area_name` | TEXT | `tablearea` | FACT_MAPPINGS | 台桌区域名称快照 | 如 `大厅``VIP` |
| `table_name` | VARCHAR(64) | `tablename` | FACT_MAPPINGS | 台桌名称快照 | 如 `1号台` |
---
## 3. 跳过字段说明
| ODS 字段 | 跳过原因 |
|---------|---------|
| `siteprofile` | JSONB 嵌套列,已由 `dim_site` / `dim_site_ex` 通过 JSONB 提取映射 |
---
## 4. 代码引用
- FACT_MAPPINGS`dwd_load_task.py``FACT_MAPPINGS["dwd.dwd_assistant_trash_event"]` / `FACT_MAPPINGS["dwd.dwd_assistant_trash_event_ex"]`
- TABLE_MAP`"dwd.dwd_assistant_trash_event" → "ods.assistant_cancellation_records"`
- DWS 下游:`dws_assistant_daily_task.py`(助教日业绩汇总,废除金额扣减)

View File

@@ -0,0 +1,68 @@
# BD 手册:会员生日字段 ETL 链路补齐C1
## 概述
为支持会员生日信息从上游 API 完整传递到 DWS 层,在 ODS 和 DWD 两层的会员表中新增 `birthday DATE` 列。ODS 层从 API payload 提取生日值落地DWD 层通过自动列映射和 SCD2 机制同步。
## 变更说明
| Schema | 表 | 变更类型 | 字段 | 类型 | 说明 |
|--------|---|---------|------|------|------|
| ods | member_profiles | 加列 | birthday | DATE | 会员生日,从上游 API payload 提取 |
| dwd | dim_member | 加列 | birthday | DATE | 会员生日ODS → DWD 自动列映射 |
## 兼容性
- ETL ConnectorDwdLoadTask 通过 `_get_columns()` 自动读取 DWD 表列名新增列会被自动包含在列映射中SCD2 变化检测自动覆盖所有非元数据列,`birthday` 无需额外配置
- 后端 API / 小程序:无影响(当前未直接读取 `dim_member`
- DWS 层:`member_visit_task``member_consumption_task``_extract_member_info()` SQL 中已引用 `birthday` 字段,通过 `COALESCE(fdw_app.member_birthday_manual.birthday_value, dim_member.birthday)` 实现手动补录优先于 API 来源的合并逻辑(详见 `BD_Manual_fdw_reverse_member_birthday.md`
## 回滚策略
```sql
BEGIN;
ALTER TABLE ods.member_profiles DROP COLUMN IF EXISTS birthday;
ALTER TABLE dwd.dim_member DROP COLUMN IF EXISTS birthday;
COMMIT;
```
回滚无数据丢失风险:`birthday` 为新增列,删除前所有值均为 NULL 或新写入的生日数据(可从 ODS payload JSONB 重新提取)。
## 验证步骤
```sql
-- 1. 确认 ods.member_profiles.birthday 列存在且类型正确
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'ods'
AND table_name = 'member_profiles'
AND column_name = 'birthday';
-- 预期1 行data_type = 'date'
-- 2. 确认 dwd.dim_member.birthday 列存在且类型正确
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'dwd'
AND table_name = 'dim_member'
AND column_name = 'birthday';
-- 预期1 行data_type = 'date'
-- 3. 确认 dwd.dim_member.birthday 列注释已设置
SELECT col_description(c.oid, a.attnum) AS column_comment
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
WHERE n.nspname = 'dwd'
AND c.relname = 'dim_member'
AND a.attname = 'birthday';
-- 预期:'会员生日来源ODS member_profiles payload 中的 birthday 字段'
```
## 关联文件
- 迁移脚本:`db/etl_feiqiu/migrations/2026-02-22__C1_dim_member_add_birthday.sql`
- 主 DDL`db/etl_feiqiu/schemas/ods.sql``member_profiles` 表)、`db/etl_feiqiu/schemas/dwd.sql``dim_member` 表)
- 需求文档:`.kiro/specs/etl-aggregation-fix/requirements.md` — 需求 4.1, 4.2, 4.3, 4.4
- ODS 入库逻辑:`apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py``member_profiles` 字段列表包含 `birthday`
- DWS 生日合并:`BD_Manual_fdw_reverse_member_birthday.md` — FDW 反向映射供 DWS 任务 COALESCE 读取
- 手动补录表:`BD_Manual_member_birthday_manual.md``zqyy_app.member_birthday_manual` 表文档

View File

@@ -0,0 +1,65 @@
# BD 手册删除助教废除Abolish独立链路表
## 迁移脚本
`db/etl_feiqiu/migrations/2026-02-22__drop_assistant_abolish_tables.sql`
## 变更说明
### 删除的表
| Schema | 表名 | 说明 |
|--------|------|------|
| `ods` | `assistant_cancellation_records` | 上游废除 API 原始数据(仅 78 条),不再抓取 |
| `dwd` | `dwd_assistant_trash_event` | 废除事件主表,无消费者 |
| `dwd` | `dwd_assistant_trash_event_ex` | 废除事件扩展表,无消费者 |
### 删除的索引
| Schema | 索引名 | 所属表 |
|--------|--------|--------|
| `ods` | `idx_ods_assistant_cancellation_records_latest` | `assistant_cancellation_records` |
### 清理的元数据
- `meta.etl_task``task_code = 'ODS_ASSISTANT_ABOLISH'` 的注册行
## 兼容性影响
- **ETL**ODS 抓取任务 `ODS_ASSISTANT_ABOLISH` 已在代码层移除Task 13迁移脚本清理数据库残留
- **DWD 加载**`FACT_MAPPINGS``TABLE_MAP` 中的废除表映射已在代码层移除
- **DWS 聚合**`assistant_daily_task.py` 已改用 `dwd_assistant_service_log_ex.is_trash` 字段,不受影响
- **后端 API**:无直接引用这些表
- **小程序**:无直接引用这些表
## 回滚策略
1.`db/etl_feiqiu/schemas/ods.sql` 恢复 `assistant_cancellation_records``CREATE TABLE`
2.`db/etl_feiqiu/schemas/dwd.sql` 恢复 `dwd_assistant_trash_event` / `_ex``CREATE TABLE`
3. 重建索引:`CREATE INDEX idx_ods_assistant_cancellation_records_latest ON ods.assistant_cancellation_records (id, fetched_at DESC);`
4. 重新注册任务:`INSERT INTO meta.etl_task (task_code, store_id, enabled) VALUES ('ODS_ASSISTANT_ABOLISH', <store_id>, TRUE);`
5. ODS 数据可从上游 API 重新抓取(仅 78 条)
## 验证 SQL
```sql
-- 1. 确认 3 张表已不存在
SELECT tablename FROM pg_tables
WHERE schemaname IN ('ods', 'dwd')
AND tablename IN (
'assistant_cancellation_records',
'dwd_assistant_trash_event',
'dwd_assistant_trash_event_ex'
);
-- 预期0 行
-- 2. 确认索引已不存在
SELECT indexname FROM pg_indexes
WHERE schemaname = 'ods'
AND indexname = 'idx_ods_assistant_cancellation_records_latest';
-- 预期0 行
-- 3. 确认 meta.etl_task 中无 ODS_ASSISTANT_ABOLISH 注册
SELECT * FROM meta.etl_task WHERE task_code = 'ODS_ASSISTANT_ABOLISH';
-- 预期0 行
```

View File

@@ -0,0 +1,93 @@
# BD 手册:助教月度汇总表唯一约束变更(需求 A
## 概述
`dws.dws_assistant_monthly_summary` 的唯一约束从 `(site_id, assistant_id, stat_month)` 变更为 `(site_id, assistant_id, stat_month, assistant_level_code)`,以支持助教月内多档位分段统计。
## 变更说明
| Schema | 表 | 变更类型 | 约束名 | 旧定义 | 新定义 |
|--------|---|---------|--------|--------|--------|
| dws | dws_assistant_monthly_summary | 改约束 | uk_dws_assistant_monthly | `(site_id, assistant_id, stat_month)` | `(site_id, assistant_id, stat_month, assistant_level_code)` |
### 变更原因
助教在同一月内可能因升级/降级而存在多个 `assistant_level_code`。旧约束只允许每个助教每月一行记录,导致:
- `AssistantMonthlyTask` 按档位分组聚合时INSERT 违反唯一约束
- 临时修复(`MAX()` 聚合)丢失了档位维度信息,无法按档位分段计算工资
新约束允许同一助教同月按不同档位生成多行记录,支持精确的分段业绩统计和工资计算。
## 兼容性
- **ETL Connector**`AssistantMonthlyTask``_extract_daily_aggregates()` 将同步修改 GROUP BY 加入 `assistant_level_code`(任务 7.2INSERT 逻辑适配多行输出
- **AssistantSalaryTask**:需适配多行月度汇总结构,按档位分段计算工资(任务 7.4
- **后端 API / 小程序**:无直接影响(当前未直接读取 `dws_assistant_monthly_summary`
- **DWS 下游任务**`AssistantFinanceTask``AssistantCustomerTask` 不依赖此表的唯一约束,无影响
## 回滚策略
```sql
-- ⚠️ 回滚前须先清理同一 (site_id, assistant_id, stat_month) 的多行数据,
-- 否则恢复旧约束会因重复键失败。
-- 步骤 1清理多档位数据保留每组最新一条
DELETE FROM dws.dws_assistant_monthly_summary a
USING (
SELECT site_id, assistant_id, stat_month,
MAX(updated_at) AS keep_updated_at
FROM dws.dws_assistant_monthly_summary
GROUP BY site_id, assistant_id, stat_month
) b
WHERE a.site_id = b.site_id
AND a.assistant_id = b.assistant_id
AND a.stat_month = b.stat_month
AND a.updated_at <> b.keep_updated_at;
-- 步骤 2恢复旧约束
BEGIN;
ALTER TABLE dws.dws_assistant_monthly_summary
DROP CONSTRAINT IF EXISTS uk_dws_assistant_monthly;
ALTER TABLE dws.dws_assistant_monthly_summary
ADD CONSTRAINT uk_dws_assistant_monthly
UNIQUE (site_id, assistant_id, stat_month);
COMMIT;
```
回滚后需重新执行 `DWS_ASSISTANT_MONTHLY` 任务以 MAX() 模式重新聚合数据。
## 验证步骤
```sql
-- 1. 确认新约束存在且列组合正确
SELECT conname, pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE conrelid = 'dws.dws_assistant_monthly_summary'::regclass
AND conname = 'uk_dws_assistant_monthly';
-- 预期1 行constraint_def 包含 (site_id, assistant_id, stat_month, assistant_level_code)
-- 2. 确认约束列数为 4
SELECT COUNT(*) AS col_count
FROM pg_constraint c
JOIN LATERAL unnest(c.conkey) AS col_num ON TRUE
WHERE c.conrelid = 'dws.dws_assistant_monthly_summary'::regclass
AND c.conname = 'uk_dws_assistant_monthly';
-- 预期4
-- 3. 确认旧的 3 列约束不存在
SELECT conname, array_length(conkey, 1) AS col_count
FROM pg_constraint
WHERE conrelid = 'dws.dws_assistant_monthly_summary'::regclass
AND contype = 'u';
-- 预期uk_dws_assistant_monthly 的 col_count = 4非 3
```
## 关联文件
- 迁移脚本:`db/etl_feiqiu/migrations/2026-02-22__A_monthly_summary_uk_change.sql`
- 主 DDL`db/etl_feiqiu/schemas/dws.sql`(已合并)
- 工资表约束变更:`BD_Manual_dws_assistant_salary_uk_change.md`
- 需求文档:`.kiro/specs/etl-aggregation-fix/requirements.md` — 需求 2.1, 2.2, 2.3
- 代码变更:
- `apps/etl/connectors/feiqiu/tasks/dws/assistant_monthly_task.py` — GROUP BY 加入 `assistant_level_code`
- `apps/etl/connectors/feiqiu/tasks/dws/assistant_salary_task.py` — 按档位分段计算工资

View File

@@ -0,0 +1,94 @@
# BD 手册:助教工资计算表唯一约束变更(需求 A — 任务 7.4
## 概述
`dws.dws_assistant_salary_calc` 的唯一约束从 `(site_id, assistant_id, salary_month)` 变更为 `(site_id, assistant_id, salary_month, assistant_level_code)`,以支持同一助教同月按不同档位分段计算工资。
## 变更说明
| Schema | 表 | 变更类型 | 约束名 | 旧定义 | 新定义 |
|--------|---|---------|--------|--------|--------|
| dws | dws_assistant_salary_calc | 改约束 | uk_dws_assistant_salary | `(site_id, assistant_id, salary_month)` | `(site_id, assistant_id, salary_month, assistant_level_code)` |
### 变更原因
上游 `dws_assistant_monthly_summary` 已按 `(site_id, assistant_id, stat_month, assistant_level_code)` 分行(任务 7.2/7.3)。`AssistantSalaryTask` 逐行读取月度汇总并计算工资,同一助教同月可能产生多条工资记录(对应不同档位)。旧约束 `(site_id, assistant_id, salary_month)` 会导致 INSERT 冲突。
### 代码变更
- `assistant_salary_task.py``get_primary_keys()` 返回值加入 `assistant_level_code`
- `_extract_monthly_summary()``transform()``_calculate_salary()` 无需修改——已天然按行处理
## 兼容性
- **ETL Connector**`AssistantSalaryTask``get_primary_keys()` 已同步更新,`upsert()` 方法使用该键做 ON CONFLICT
- **`_delete_by_month()`**:按 `site_id + salary_month` 整月删除后重新插入,不受约束变更影响
- **后端 API / 小程序**:无直接影响(当前未直接读取 `dws_assistant_salary_calc`
- **DWS 下游任务**`AssistantFinanceDailyTask` 读取 salary_calc 做日度成本分摊,需确认按 assistant_id 聚合时能正确处理多档位行(现有逻辑按 assistant_id + stat_date 聚合,不受影响)
## 回滚策略
```sql
-- ⚠️ 回滚前须先清理同一 (site_id, assistant_id, salary_month) 的多行数据,
-- 否则恢复旧约束会因重复键失败。
-- 步骤 1清理多档位数据保留每组最新一条
DELETE FROM dws.dws_assistant_salary_calc a
USING (
SELECT site_id, assistant_id, salary_month,
MAX(updated_at) AS keep_updated_at
FROM dws.dws_assistant_salary_calc
GROUP BY site_id, assistant_id, salary_month
) b
WHERE a.site_id = b.site_id
AND a.assistant_id = b.assistant_id
AND a.salary_month = b.salary_month
AND a.updated_at <> b.keep_updated_at;
-- 步骤 2恢复旧约束
BEGIN;
ALTER TABLE dws.dws_assistant_salary_calc
DROP CONSTRAINT IF EXISTS uk_dws_assistant_salary;
ALTER TABLE dws.dws_assistant_salary_calc
ADD CONSTRAINT uk_dws_assistant_salary
UNIQUE (site_id, assistant_id, salary_month);
COMMIT;
```
回滚后需重新执行 `DWS_ASSISTANT_SALARY` 任务,此时月度汇总表也应已回滚为单行模式。
## 验证步骤
```sql
-- 1. 确认新约束存在且列组合正确
SELECT conname, pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE conrelid = 'dws.dws_assistant_salary_calc'::regclass
AND conname = 'uk_dws_assistant_salary';
-- 预期1 行constraint_def 包含 (site_id, assistant_id, salary_month, assistant_level_code)
-- 2. 确认约束列数为 4
SELECT COUNT(*) AS col_count
FROM pg_constraint c
JOIN LATERAL unnest(c.conkey) AS col_num ON TRUE
WHERE c.conrelid = 'dws.dws_assistant_salary_calc'::regclass
AND c.conname = 'uk_dws_assistant_salary';
-- 预期4
-- 3. 测试同一助教同月不同档位可共存
INSERT INTO dws.dws_assistant_salary_calc
(site_id, tenant_id, assistant_id, salary_month, assistant_level_code)
VALUES (99999, 99999, 99999, '2099-01-01', 10),
(99999, 99999, 99999, '2099-01-01', 20);
-- 预期:成功插入 2 行
DELETE FROM dws.dws_assistant_salary_calc
WHERE site_id = 99999 AND assistant_id = 99999 AND salary_month = '2099-01-01';
```
## 关联文件
- 迁移脚本:`db/etl_feiqiu/migrations/2026-02-22__A_salary_calc_uk_change.sql`
- 主 DDL`db/etl_feiqiu/schemas/dws.sql``db/etl_feiqiu/schemas/schema_dws.sql`
- 代码:`apps/etl/connectors/feiqiu/tasks/dws/assistant_salary_task.py`
- 需求文档:`.kiro/specs/etl-aggregation-fix/requirements.md` — 需求 2.4
- 前置任务7.1monthly_summary UK 变更、7.2/7.3(月度聚合按档位分行)

View File

@@ -0,0 +1,105 @@
# BD 手册FDW 反向映射 — ETL 库读取业务库会员生日
## 概述
`etl_feiqiu`(生产)/ `test_etl_feiqiu`(测试)数据库中,通过 `postgres_fdw` 创建指向 `zqyy_app` / `test_zqyy_app` 的外部表 `fdw_app.member_birthday_manual`,使 ETL DWS 任务可只读访问助教手动补录的会员生日数据。
方向:`etl_feiqiu → zqyy_app`(与现有 `setup_fdw.sql``zqyy_app → etl_feiqiu` 方向相反)。
## 变更说明
| 库 | Schema | 对象 | 变更类型 | 说明 |
|----|--------|------|---------|------|
| etl_feiqiu / test_etl_feiqiu | — | zqyy_app_server / test_zqyy_app_server | 新建外部服务器 | 指向业务库 |
| etl_feiqiu / test_etl_feiqiu | — | etl_user → app_reader 映射 | 新建用户映射 | 只读角色映射 |
| etl_feiqiu / test_etl_feiqiu | fdw_app | — | 新建 schema | 存放来自业务库的外部表 |
| etl_feiqiu / test_etl_feiqiu | fdw_app | member_birthday_manual | 新建外部表 | 映射业务库 public.member_birthday_manual |
### 外部表列定义
| 列名 | 类型 | 说明 |
|------|------|------|
| id | BIGINT | 自增主键(源表 BIGSERIAL |
| member_id | BIGINT | 会员 ID |
| birthday_value | DATE | 补录的生日值 |
| recorded_by_assistant_id | BIGINT | 提交助教 ID |
| recorded_by_name | VARCHAR(50) | 提交助教姓名 |
| recorded_at | TIMESTAMPTZ | 提交时间 |
| source | VARCHAR(20) | 数据来源标识 |
| site_id | BIGINT | 门店 ID多门店隔离Requirements 13.1 |
### 角色说明
| 角色 | 所在库 | 用途 |
|------|--------|------|
| etl_user | etl_feiqiu | ETL 连接角色,通过 FDW 读取业务库数据 |
| app_reader | zqyy_app | 业务库只读角色,供 FDW 用户映射使用 |
## 兼容性
- **ETL Connector**DWS 任务(`member_visit_task``member_consumption_task`)通过 `fdw_app.member_birthday_manual` 读取手动补录生日,使用 `COALESCE(手动补录值, dim_member.birthday)` 合并
- **后端 API**:无影响,后端直接写入 `zqyy_app.member_birthday_manual`
- **小程序**:无影响
- **现有 FDW**:与现有 `setup_fdw.sql`zqyy_app → etl_feiqiu 方向)互不干扰,使用不同的 server 名和 schema 名
## 回滚策略
在 ETL 库(`etl_feiqiu``test_etl_feiqiu`)中按逆序执行:
```sql
ALTER DEFAULT PRIVILEGES IN SCHEMA fdw_app REVOKE SELECT ON TABLES FROM etl_user;
REVOKE SELECT ON ALL TABLES IN SCHEMA fdw_app FROM etl_user;
REVOKE USAGE ON SCHEMA fdw_app FROM etl_user;
DROP FOREIGN TABLE IF EXISTS fdw_app.member_birthday_manual;
DROP SCHEMA IF EXISTS fdw_app CASCADE;
DROP USER MAPPING IF EXISTS FOR etl_user SERVER zqyy_app_server; -- 生产
-- DROP USER MAPPING IF EXISTS FOR etl_user SERVER test_zqyy_app_server; -- 测试
DROP SERVER IF EXISTS zqyy_app_server CASCADE; -- 生产
-- DROP SERVER IF EXISTS test_zqyy_app_server CASCADE; -- 测试
```
回滚后 DWS 任务的生日读取将降级为仅使用 `dim_member.birthday`API 来源),需确保降级逻辑已实现(任务 9.3)。
## 验证步骤
```sql
-- 1. 确认外部服务器存在
SELECT srvname, srvoptions
FROM pg_foreign_server
WHERE srvname IN ('zqyy_app_server', 'test_zqyy_app_server');
-- 预期1 行(生产或测试环境对应的 server
-- 2. 确认用户映射存在
SELECT s.srvname, um.umoptions
FROM pg_user_mapping um
JOIN pg_foreign_server s ON um.umserver = s.oid
WHERE s.srvname IN ('zqyy_app_server', 'test_zqyy_app_server');
-- 预期1 行
-- 3. 确认 fdw_app schema 存在
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name = 'fdw_app';
-- 预期1 行
-- 4. 确认外部表列结构完整
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'fdw_app'
AND table_name = 'member_birthday_manual'
ORDER BY ordinal_position;
-- 预期8 列
-- 5. 确认外部表可读取(需业务库侧表已存在且网络连通)
SELECT COUNT(*) FROM fdw_app.member_birthday_manual;
-- 预期:返回行数(可能为 0
```
## 关联文件
- 生产环境脚本:`db/fdw/setup_fdw_reverse.sql`
- 测试环境脚本:`db/fdw/setup_fdw_reverse_test.sql`
- 源表迁移脚本:`db/zqyy_app/migrations/2026-02-22__C2_member_birthday_manual.sql`
- 源表文档:`docs/database/BD_Manual_member_birthday_manual.md`
- 现有正向 FDW`db/fdw/setup_fdw.sql`zqyy_app → etl_feiqiu 方向)
- 需求文档:`.kiro/specs/etl-aggregation-fix/requirements.md` — 需求 5.3

View File

@@ -0,0 +1,56 @@
# BD 手册DWD 层 BC 哨兵日期修复
## 概述
上游飞球 API 对"未设置"的日期字段返回哨兵值 `0001-01-01T00:00:00`。该值在 ODS 层以 `timestamp without time zone` 存储,转入 DWD 层 `timestamp with time zone` 列时PostgreSQL 在 `Asia/Shanghai` 时区下将其转换为 `0001-12-31 23:59:43 BC`公元前日期。psycopg2 无法解析 BC 日期,导致 `ValueError: year -1 is out of range`
## 受影响对象
| Schema | 表 | 列 | ODS 源列类型 | DWD 列类型 | 受影响行数 |
|--------|---|---|---|---|---|
| dwd | dim_assistant_ex | birth_date | timestamp | timestamptz | ~1,107 |
| dwd | dim_member_card_account_ex | disable_start_time | timestamp | timestamptz | ~18,172 |
| dwd | dim_member_card_account_ex | disable_end_time | timestamp | timestamptz | ~18,172 |
| dwd | dwd_assistant_service_log_ex | composite_grade_time | timestamp | timestamptz | ~5,297 |
| dwd | dwd_recharge_order_ex | revoke_time | timestamp | timestamptz | ~485 |
| dwd | dwd_settlement_head_ex | revoke_time | timestamp | timestamptz | ~26,435 |
## 修复方案
### 存量数据
迁移脚本 `2026-02-22__fix_bc_sentinel_dates_to_null.sql`:将所有 `EXTRACT(year) < 1` 的 BC 日期置为 NULL。
### 增量防御(代码层)
`dwd_load_task.py` 的 SQL 表达式构造中加入哨兵值过滤(阈值 `0002-01-01`
1. `_cast_expr`:对 `timestamptz` CAST 包裹 `CASE WHEN (base)::timestamp >= '0002-01-01'::timestamp THEN ... ELSE NULL END`
2. `_build_fact_select_exprs`:事实表 `timestamp → timestamptz` 同类型列加过滤
3. `_merge_dim_scd2`:维度表 ODS→DWD SELECT 和 DWD 现有数据读取均加过滤
### 设计决策
- 阈值选 `0002-01-01` 而非 `0001-01-02`:留出安全余量,公元 1 年的日期在业务上不可能出现
- BC 日期 → NULL 而非保留原值:哨兵值本身无业务含义("未设置"NULL 语义更准确
- `(base)::timestamp` 显式 CAST因为 base 可能是 JSONB `->>'key'` 提取的 text 类型,不能直接与 timestamp 比较
## 兼容性
- ETL Connectorv10 验证 19/19 任务全部成功0 错误
- 后端 API / 小程序:无影响
- DWS 层:无影响(不引用这些时间列)
## 回滚
存量数据回滚不可行BC 日期是错误数据)。代码回滚需还原 `_cast_expr``_build_fact_select_exprs``_merge_dim_scd2` 中的哨兵过滤逻辑。
## 验证
`docs/database/etl_feiqiu_schema_migration.md` 迁移 11 的验证 SQL。
## 关联文件
- 迁移脚本:`db/etl_feiqiu/migrations/2026-02-22__fix_bc_sentinel_dates_to_null.sql`
- 代码修改:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
- 存量修复脚本(已废弃):`scripts/ops/fix_bc_dates.py`

View File

@@ -0,0 +1,104 @@
# BD 手册助教手动补录会员生日表C2
## 概述
为支持助教手动提交客户生日信息(上游 API 未提供时的补充渠道),在 `zqyy_app` / `test_zqyy_app` 业务库中新建 `member_birthday_manual` 表。该表通过 FDW 只读映射供 ETL DWS 任务读取,与 `dim_member.birthday`API 来源)配合实现生日数据的双源合并。
## 变更说明
| 库 | Schema | 表 | 变更类型 | 说明 |
|----|--------|---|---------|------|
| zqyy_app / test_zqyy_app | public | member_birthday_manual | 新建表 | 助教手动补录的会员生日信息 |
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| member_id | BIGINT | NOT NULL | 会员 ID |
| birthday_value | DATE | NOT NULL | 补录的生日值 |
| recorded_by_assistant_id | BIGINT | — | 提交助教 ID |
| recorded_by_name | VARCHAR(50) | — | 提交助教姓名 |
| recorded_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 提交时间 |
| source | VARCHAR(20) | DEFAULT 'assistant' | 数据来源标识 |
| site_id | BIGINT | NOT NULL | 门店 ID多门店隔离Requirements 13.1 |
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| member_birthday_manual_pkey | PRIMARY KEY | id | 主键 |
| uk_member_birthday_manual | UNIQUE | (member_id, recorded_by_assistant_id) | 同一助教对同一会员只保留一条记录,支持 UPSERT |
| idx_mbd_member | INDEX (btree) | member_id | 加速按会员 ID 查询 |
| idx_mbd_site_id | INDEX (btree) | site_id | 加速按门店 ID 查询 |
## 兼容性
- **ETL Connector**:后续通过 FDW 反向映射(`fdw_app.member_birthday_manual`)在 ETL 库中只读访问DWS 任务使用 `COALESCE(手动补录值, API 值)` 合并生日数据
- **后端 API**:新增 `POST /member-birthday` 接口执行 UPSERT 写入(任务 9.4
- **小程序**:助教端调用后端 API 提交生日,无直接数据库访问
- **现有数据**:新建表,无历史数据影响
## 回滚策略
```sql
BEGIN;
DROP TABLE IF EXISTS member_birthday_manual CASCADE;
COMMIT;
```
回滚无数据丢失风险:该表为新建表,回滚前的数据均为手动补录的生日信息,可由助教重新提交。若已建立 FDW 映射,需先在 ETL 库中删除外部表 `fdw_app.member_birthday_manual`
## 验证步骤
```sql
-- 1. 确认表存在
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'member_birthday_manual';
-- 预期1 行
-- 2. 确认唯一约束 uk_member_birthday_manual 存在
SELECT conname, contype
FROM pg_constraint
WHERE conrelid = 'member_birthday_manual'::regclass
AND conname = 'uk_member_birthday_manual';
-- 预期1 行contype = 'u'
-- 3. 确认索引 idx_mbd_member 存在
SELECT indexname
FROM pg_indexes
WHERE tablename = 'member_birthday_manual'
AND indexname = 'idx_mbd_member';
-- 预期1 行
-- 4. 确认表注释已设置
SELECT obj_description('member_birthday_manual'::regclass, 'pg_class');
-- 预期:'助教手动补录的会员生日信息'
-- 5. 确认列结构完整
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'member_birthday_manual'
ORDER BY ordinal_position;
-- 预期8 列id, member_id, birthday_value, recorded_by_assistant_id,
-- recorded_by_name, recorded_at, source, site_id
-- 6. 确认 site_id 索引存在
SELECT indexname
FROM pg_indexes
WHERE tablename = 'member_birthday_manual'
AND indexname = 'idx_mbd_site_id';
-- 预期1 行
```
## 关联文件
- 迁移脚本:`db/zqyy_app/migrations/2026-02-22__C2_member_birthday_manual.sql`
- FDW 反向映射(生产):`db/fdw/setup_fdw_reverse.sql` — 在 etl_feiqiu 中创建 `fdw_app.member_birthday_manual` 外部表
- FDW 反向映射(测试):`db/fdw/setup_fdw_reverse_test.sql` — 在 test_etl_feiqiu 中创建外部表
- FDW 反向映射文档:`docs/database/BD_Manual_fdw_reverse_member_birthday.md`
- 需求文档:`.kiro/specs/etl-aggregation-fix/requirements.md` — 需求 5.1, 5.3
- 后续任务9.3DWS 任务生日读取优先级、9.4(后端 API 接口)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
# zqyy_app — Web 管理后台表结构文档
## 变更说明
`zqyy_app` 数据库中新增 4 张表,支撑 Web 管理后台的用户认证、任务队列、执行日志和定时调度功能。
| 操作 | 表名 | 说明 |
|------|------|------|
| 新增 | admin_users | 管理后台操作员账户,绑定门店 site_id |
| 新增 | task_queue | ETL 任务执行队列,支持排序和状态流转 |
| 新增 | task_execution_log | 任务执行历史记录,含日志和摘要 |
| 新增 | scheduled_tasks | 定时调度任务,支持 5 种调度类型 |
### 新增字段概览
#### admin_users
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | SERIAL | PK | 自增主键 |
| username | VARCHAR(64) | UNIQUE NOT NULL | 登录用户名 |
| password_hash | VARCHAR(256) | NOT NULL | bcrypt 密码哈希 |
| display_name | VARCHAR(128) | | 显示名称 |
| site_id | BIGINT | NOT NULL | 绑定的门店 ID |
| is_active | BOOLEAN | DEFAULT TRUE | 是否启用 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |
#### task_queue
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID | PK, DEFAULT gen_random_uuid() | 队列任务 ID |
| site_id | BIGINT | NOT NULL | 门店隔离 |
| config | JSONB | NOT NULL | 序列化的 TaskConfig |
| status | VARCHAR(20) | NOT NULL, DEFAULT 'pending' | pending/running/success/failed/cancelled |
| position | INTEGER | NOT NULL, DEFAULT 0 | 队列排序位置 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| started_at | TIMESTAMPTZ | | 开始执行时间 |
| finished_at | TIMESTAMPTZ | | 执行完成时间 |
| exit_code | INTEGER | | 子进程退出码 |
| error_message | TEXT | | 错误信息 |
#### task_execution_log
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID | PK, DEFAULT gen_random_uuid() | 日志 ID |
| queue_id | UUID | FK → task_queue(id) | 关联的队列任务(可空) |
| site_id | BIGINT | NOT NULL | 门店隔离 |
| task_codes | TEXT[] | NOT NULL | 执行的任务编码列表 |
| status | VARCHAR(20) | NOT NULL | 执行状态 |
| started_at | TIMESTAMPTZ | NOT NULL | 开始时间 |
| finished_at | TIMESTAMPTZ | | 结束时间 |
| exit_code | INTEGER | | 退出码 |
| duration_ms | INTEGER | | 执行时长(毫秒) |
| command | TEXT | | 实际执行的 CLI 命令 |
| output_log | TEXT | | stdout 完整日志 |
| error_log | TEXT | | stderr 日志 |
| summary | JSONB | | 执行摘要 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 记录创建时间 |
#### scheduled_tasks
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID | PK, DEFAULT gen_random_uuid() | 调度任务 ID |
| site_id | BIGINT | NOT NULL | 门店隔离 |
| name | VARCHAR(256) | NOT NULL | 调度任务名称 |
| task_codes | TEXT[] | NOT NULL | 任务编码列表 |
| task_config | JSONB | NOT NULL | 序列化的 TaskConfig |
| schedule_config | JSONB | NOT NULL | 序列化的 ScheduleConfig |
| enabled | BOOLEAN | DEFAULT TRUE | 是否启用 |
| last_run_at | TIMESTAMPTZ | | 上次执行时间 |
| next_run_at | TIMESTAMPTZ | | 下次执行时间 |
| run_count | INTEGER | DEFAULT 0 | 累计执行次数 |
| last_status | VARCHAR(20) | | 上次执行状态 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |
### 索引
| 索引名 | 表 | 列 | 类型 | 说明 |
|--------|------|------|------|------|
| idx_admin_users_site | admin_users | site_id | B-tree | 按门店查询用户 |
| idx_task_queue_status | task_queue | status | B-tree | 按状态查询队列 |
| idx_task_queue_site_position | task_queue | site_id, position | 部分索引 (WHERE status='pending') | 取出待执行任务 |
| idx_execution_log_site_started | task_execution_log | site_id, started_at DESC | B-tree | 执行历史列表 |
| idx_scheduled_tasks_site | scheduled_tasks | site_id | B-tree | 按门店查询调度 |
| idx_scheduled_tasks_next_run | scheduled_tasks | next_run_at | 部分索引 (WHERE enabled=TRUE) | 查询到期调度 |
### 种子数据
- 默认管理员:`admin` / `admin123`site_id=1
- 种子脚本:`db/zqyy_app/seeds/admin_web_seed.sql`
## 兼容性
- **ETL Connector**:无影响。新表位于 `zqyy_app`ETL 数据仍在 `etl_feiqiu`
- **后端 API**:新增的 FastAPI 路由将读写这 4 张表,需配置 `zqyy_app` 数据库连接
- **小程序**:无影响。小程序通过 FDW 访问 ETL 数据,不涉及管理后台表
- **现有 zqyy_app 表**:无影响。新表与现有 users/roles/tasks 等表无外键关联
## 回滚策略
```sql
-- 按依赖顺序删除task_execution_log 引用 task_queue
BEGIN;
DROP TABLE IF EXISTS task_execution_log CASCADE;
DROP TABLE IF EXISTS task_queue CASCADE;
DROP TABLE IF EXISTS scheduled_tasks CASCADE;
DROP TABLE IF EXISTS admin_users CASCADE;
COMMIT;
```
## 验证 SQL
```sql
-- 1. 验证 4 张表均已创建
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('admin_users', 'task_queue', 'task_execution_log', 'scheduled_tasks')
ORDER BY table_name;
-- 2. 验证索引已创建(应返回 6 条)
SELECT indexname, tablename
FROM pg_indexes
WHERE tablename IN ('admin_users', 'task_queue', 'task_execution_log', 'scheduled_tasks')
AND indexname LIKE 'idx_%'
ORDER BY tablename, indexname;
-- 3. 验证默认管理员已插入
SELECT id, username, display_name, site_id, is_active
FROM admin_users
WHERE username = 'admin';
-- 4. 验证 task_queue 的部分索引条件
SELECT indexname, indexdef
FROM pg_indexes
WHERE indexname = 'idx_task_queue_site_position';
-- 5. 验证 task_execution_log 的外键约束
SELECT conname, conrelid::regclass, confrelid::regclass
FROM pg_constraint
WHERE conrelid = 'task_execution_log'::regclass
AND contype = 'f';
```