🧬 FAERS 清洗详细参考代码 R 语言

⚙️ 解压 · 合并 · 去重 · 标准化 等

# ============================================
#数据预处理流程:
#1.合并多个季度文件(DEMO、DRUG、REAC、OUTC、INDI、THER、RPSR)
#2.应用Deleted文件删除废弃Case(从2019Q1 起)
#3.去重(官方推荐的规则)
#4.用去重后的 PRIMARYID 连接其它表(DRUG / REAC / OUTC / INDI / THER / RPSR)
#5.标准化药名
#6.标准化不良事件(REAC)与 Indication(INDI),绑定 MedDRA 版本
#7.清洗人口学字段、时间字段、结果字段(AGE, WT, OUTC, SERIOUSNESS)
#8.最后的质量控制

# 设置原始 zip 文件所在文件夹路径
zip_dir <- "E:\\Faers\\zip"  # 替换为你的 zip 文件所在路径
# 设置所有 zip 文件解压后的主文件夹路径
output_main_dir <- "E:\\Faers\\2021_2025"  # 替换为你想存放解压文件夹的位置
# 如果主文件夹不存在,则创建它
if (!dir.exists(output_main_dir)) {
  dir.create(output_main_dir, recursive = TRUE)
}
# 获取所有 zip 文件完整路径
zip_files <- list.files(zip_dir, pattern = "\\.zip$", full.names = TRUE)
# 循环处理每个 zip 文件
for (zip_file in zip_files) {
  # 提取文件名(不含路径和 .zip 后缀)
  zip_name <- tools::file_path_sans_ext(basename(zip_file))
  # 创建解压的目标子文件夹路径
  output_subdir <- file.path(output_main_dir, zip_name)
  # 如果子文件夹不存在,则创建
  if (!dir.exists(output_subdir)) {
    dir.create(output_subdir, recursive = TRUE)
  }
  # 解压到目标子文件夹
  unzip(zip_file, exdir = output_subdir)
  # 打印状态信息
  cat("已解压:", zip_name, " 到 ", output_subdir, "\n")
}
cat(" 所有文件已成功解压完毕!共解压 ", length(zip_files), " 个 zip 文件。\n")


# ============================================
#加载必要的包
library(readr)    # 用于高效读取文本文件
library(dplyr)    # 用于数据处理和合并

#设置FAERS主目录路径(含多个子季度)
faers_dir <- "E:\\Faers\\2021_2025"

#设置合并结果的输出目录
output_dir <- "E:\\Faers\\merge"

#如果输出目录不存在,则创建
if (!dir.exists(output_dir)) {
  dir.create(output_dir, recursive = TRUE)
}

#要处理的7种FAERS文件类型c("DEMO", "DRUG", "REAC", "OUTC", "RPSR", "THER", "INDI")
file_types <- c("DEMO", "DRUG", "REAC","OUTC", "RPSR", "THER", "INDI")

#初始化列表以存储每种类型合并的数据框
merged_data_list <- list()

#遍历每种文件类型,查找并合并所有相关文件
for (type in file_types) {
  cat("正在处理类型:", type, "\n")
  
  # 在所有子文件夹中查找以该类型开头的txt文件(不区分大小写)
  files <- list.files(path = faers_dir,
                      pattern = paste0("^", type, ".*\\.txt$"),
                      recursive = TRUE,
                      full.names = TRUE,
                      ignore.case = TRUE)
  
  if (length(files) == 0) {
    warning(paste("未找到类型", type, "的文件,跳过此类型"))
    next
  }
  
  # 逐个文件读取并合并
  type_data <- do.call(rbind, lapply(files, function(f) {
    read_tsv(f, show_col_types = FALSE, progress = FALSE)
  }))
  
  # 保存合并结果到列表
  merged_data_list[[type]] <- type_data
  
  # 保存为新的txt文件到指定输出目录
  output_file <- file.path(output_dir, paste0("Merged_", type, ".txt"))
  write_tsv(type_data, file = output_file)
  
  cat( type, "合并完成,共", nrow(type_data), "行,保存为:", output_file, "\n")
}


# ============================================
#   FAERS 数据清洗(按 caseid 删除)
#   直接删除 Delete 文件中的 caseid 记录
#   输出格式统一为 .tsv(制表符分隔)
# ============================================

#加载必要的包
library(readr)     # 高效读取 / 写入 TSV 文件
library(dplyr)     # 数据处理
library(stringr)   # 字符串处理
library(purrr)     # 函数式编程
library(fs)        # 文件路径处理

#设置路径
input_dir  <- "E:/Faers/merge"        # 已合并的主表路径(Merged_xxx.txt)
faers_root <- "E:/Faers/2021_2025"    # 含所有季度原始文件(含 Delete 文件)
output_dir <- "E:/Faers/cleaned"      # 清洗结果保存路径

#如果输出目录不存在则创建
if (!dir_exists(output_dir)) dir_create(output_dir)


# 汇总 Delete 文件中的 caseid
cat("正在查找 Delete 文件...\n")
delete_files <- dir_ls(
  faers_root,
  recurse = TRUE,
  regexp = "(?i)delete.*\\.txt$",  # 匹配各种 Delete 文件(忽略大小写)
  type = "file"
)

cat("读取并汇总 Delete 文件中的 caseid...\n")

delete_caseids <- map(delete_files, function(f) {
  lines <- read_lines(f)                     # 逐行读取
  lines <- lines[!str_detect(lines, "deleted|caseid|^\\s*$")] # 去除表头、空行
  str_trim(lines)                             # 去除多余空格
}) |> unlist() |> unique()                    # 展平 + 去重

# 保存汇总 caseid 结果(可选)
write_tsv(data.frame(caseid = delete_caseids),
          file.path(output_dir, "All_Deleted_CASEIDs.tsv"))

cat(" 汇总删除 CASEID 数量:", length(delete_caseids), "\n") 


# 按 caseid 删除并保存结果
file_types <- c("DEMO", "DRUG", "REAC", "OUTC", "RPSR", "THER", "INDI")

for (type in file_types) {
  cat("正在清洗:", type, "...\n")
  
  input_file <- file.path(input_dir, paste0("Merged_", type, ".txt"))
  
  # 如果输入文件不存在,跳过
  if (!file_exists(input_file)) {
    warning("未找到文件:", input_file)
    next
  }
  
  # 读取合并表(分隔符是 $)
  data <- read_delim(
    input_file,
    delim = "$",
    quote = "",
    escape_double = FALSE,
    col_types = cols(.default = "c"),
    show_col_types = FALSE
  ) %>% rename_with(tolower)  # 字段名统一为小写,方便匹配
  
  # 如果没有 caseid 字段,则跳过
  if (!"caseid" %in% names(data)) {
    warning( type, " 文件中未找到 'caseid' 字段,跳过")
    next
  }
  
  # 删除出现在 Delete 列表中的记录
  cleaned <- data %>%
    filter(!caseid %in% delete_caseids)
  
  # 输出为 .tsv(标准制表符分隔)
  output_file <- file.path(output_dir, paste0("Cleaned_", type, ".tsv"))
  write_tsv(cleaned, file = output_file)
  
  cat("已保存清洗后的 ", type, " 文件,共 ", nrow(cleaned), " 条记录。\n")
}

cat("全部处理完成!清洗结果已保存到:", output_dir, "\n")

# ============================================
# (补充QC报告)应用Deleted文件删除废弃Case
library(readr)
library(dplyr)
library(fs)

#路径设置
input_dir  <- "E:/Faers/merge"        # 清洗前文件夹 (Merged_xxx.txt)
output_dir <- "E:/Faers/cleaned"      # 清洗后文件夹 (Cleaned_xxx.tsv)

#自动读取删除的 caseid / primaryid 列表
delete_caseids <- read_tsv(file.path(output_dir, "All_Deleted_CASEIDs.tsv"),
                           show_col_types = FALSE)$caseid
delete_primaryids <- unique(read_tsv(file.path(output_dir, "All_Deleted_CASEIDs.tsv"),
                                     show_col_types = FALSE)$caseid)

#初始化 QC 结果表
qc_results <- data.frame(
  table_name = character(),
  caseid_before = integer(),
  caseid_after = integer(),
  primaryid_before = integer(),
  primaryid_after = integer(),
  deleted_caseid_n = integer(),
  deleted_primaryid_n = integer(),
  delete_ratio_percent = numeric(),
  status = character(),
  stringsAsFactors = FALSE
)

#要检查的表
check_tables <- c("DEMO", "DRUG", "REAC", "OUTC", "RPSR", "THER", "INDI")

for (tbl in check_tables) {
  
  # 读取清洗前数据
  merged_file <- file.path(input_dir, paste0("Merged_", tbl, ".txt"))
  if (!file.exists(merged_file)) next
  before_data <- read_delim(merged_file, delim = "$", 
                            col_types = cols(.default = "c"), 
                            show_col_types = FALSE)
  
  # 注意大小写,统一转小写
  names(before_data) <- tolower(names(before_data))
  
  before_caseid_n    <- if ("caseid" %in% names(before_data)) length(unique(before_data$caseid)) else NA
  before_primaryid_n <- if ("primaryid" %in% names(before_data)) length(unique(before_data$primaryid)) else NA
  
  # 读取清洗后数据
  cleaned_file <- file.path(output_dir, paste0("Cleaned_", tbl, ".tsv"))
  if (!file.exists(cleaned_file) && tbl == "DEMO") {
    cleaned_file <- file.path(output_dir, "Cleaned_DEMO.tsv")
  }
  if (!file.exists(cleaned_file)) next
  after_data <- read_tsv(cleaned_file, col_types = cols(.default = "c"), show_col_types = FALSE)
  names(after_data) <- tolower(names(after_data))
  
  after_caseid_n    <- if ("caseid" %in% names(after_data)) length(unique(after_data$caseid)) else NA
  after_primaryid_n <- if ("primaryid" %in% names(after_data)) length(unique(after_data$primaryid)) else NA
  
  # 计算删除数量
  deleted_caseid_n    <- if (!is.na(before_caseid_n) & !is.na(after_caseid_n)) before_caseid_n - after_caseid_n else 0
  deleted_primaryid_n <- if (!is.na(before_primaryid_n) & !is.na(after_primaryid_n)) before_primaryid_n - after_primaryid_n else 0
  
  # 删除比例(基于 primaryid)
  delete_ratio <- if (!is.na(before_primaryid_n) & before_primaryid_n > 0) {
    round(deleted_primaryid_n / before_primaryid_n * 100, 2)
  } else 0
  
  # 检查是否真的删干净了
  still_has_deleted <- FALSE
  if ("caseid" %in% names(after_data)) {
    if (length(intersect(unique(after_data$caseid), delete_caseids)) > 0) still_has_deleted <- TRUE
  }
  if ("primaryid" %in% names(after_data)) {
    if (length(intersect(unique(after_data$primaryid), delete_primaryids)) > 0) still_has_deleted <- TRUE
  }
  
  status <- ifelse(still_has_deleted, "NOT_OK", "OK")
  
  # 加入 QC 结果表
  qc_results <- rbind(qc_results, data.frame(
    table_name = tbl,
    caseid_before = before_caseid_n,
    caseid_after = after_caseid_n,
    primaryid_before = before_primaryid_n,
    primaryid_after = after_primaryid_n,
    deleted_caseid_n = deleted_caseid_n,
    deleted_primaryid_n = deleted_primaryid_n,
    delete_ratio_percent = delete_ratio,
    status = status,
    stringsAsFactors = FALSE
  ))
}

#输出 QC 报告
qc_file <- file.path(output_dir, "Deleted_Case_QC_Report.csv")
write_csv(qc_results, qc_file)
cat("删除验证报告已生成:", qc_file, "\n")


# ============================================
# ============================
# FDA 推荐规则的 DEMO 去重脚本
# 规则:对相同 CASEID,保留 FDA_DT 最大的记录;若 FDA_DT 相同,保留 PRIMARYID 最大的记录
# 入口:使用“已应用 DeletedCases 删除”的 DEMO(Cleaned_DEMO.tsv)
# 产物:Deduped_DEMO.tsv(去重后 DEMO);DEMO_Dedup_QC.csv(去重前后对照)
# ============================

#1) 加载依赖包
library(readr)      # 高效读写分隔文本
library(dplyr)      # 数据处理
library(lubridate)  # 日期解析
library(stringr)    # 字符串处理
library(fs)         # 文件路径与目录操作

#2) 基本路径与文件名配置(按需修改)
input_dir  <- "E:/Faers/cleaned"     # 你“删除废弃 case”后输出 DEMO 的目录
output_dir <- "E:/Faers/dedup"       # 去重结果与 QC 报告输出目录
if (!dir_exists(output_dir)) dir_create(output_dir)  # 若无则创建

# 输入 DEMO(已删除无效 case)——如果你想直接用合并后的 DEMO 去重,把下面改成 Merged_DEMO.txt 且把 delim 改为 "$"
input_demo_file <- file.path(input_dir, "Cleaned_DEMO.tsv")
input_delim     <- "\t"                  # Cleaned_DEMO.tsv 是你用 write_tsv 写出的,分隔符为制表符

# 输出文件
dedup_demo_file <- file.path(output_dir, "Deduped_DEMO.tsv")      # 去重后的 DEMO
qc_csv_file     <- file.path(output_dir, "DEMO_Dedup_QC.csv")     # 去重 QC 报告
removed_file    <- file.path(output_dir, "Removed_PRIMARYIDs.tsv") # 被删除的 PRIMARYID(审计用,可选)

#3) 读入 DEMO(已删除废弃 case)
cat("读取 DEMO:", input_demo_file, "\n")
# 用 read_delim 读取,并把所有列先按字符型读入,避免类型误判
demo_raw <- read_delim(
  input_demo_file,
  delim = input_delim,
  col_types = cols(.default = "c"),
  show_col_types = FALSE,
  progress = TRUE,
  trim_ws = TRUE
)

#4) 统一列名大小写并做存在性检查
demo <- demo_raw %>%
  rename_with(tolower)  # 全部列名转小写,便于后续稳健引用

# 检查关键字段是否存在(必须有 caseid、primaryid、fda_dt)
required_cols <- c("caseid", "primaryid", "fda_dt")
missing_cols  <- setdiff(required_cols, names(demo))
if (length(missing_cols) > 0) {
  stop(" DEMO 缺少关键字段:", paste(missing_cols, collapse = ", "),
       "\n请检查输入文件是否为官方 DEMO 结构,或列名是否被意外改动。")
}

# 5) 去重前的计数(QC 基线)
n_rows_before        <- nrow(demo)                         # 去重前行数
n_caseid_before      <- n_distinct(demo$caseid)            # 去重前唯一 CASEID 数
n_primaryid_before   <- n_distinct(demo$primaryid)         # 去重前唯一 PRIMARYID 数

#  6) 解析 FDA_DT(尽量保证鲁棒性)
# 官方常见格式为 YYYYMMDD;也可能是其它常见日期格式,这里用多轮尝试保证尽可能解析成功
parse_fda_dt_safe <- function(x) {
  x <- str_trim(x)                                         # 先去首尾空白
  y <- suppressWarnings(ymd(x))                            # 第一轮:YYYY-MM-DD 或 YYYYMMDD
  idx_na <- is.na(y)                                       # 记录未解析的索引
  if (any(idx_na)) {
    y[idx_na] <- suppressWarnings(mdy(x[idx_na]))          # 第二轮:MM/DD/YYYY
  }
  idx_na <- is.na(y)
  if (any(idx_na)) {
    y[idx_na] <- suppressWarnings(ymd_hms(x[idx_na]))      # 第三轮:带时分秒
  }
  return(y)                                                # 返回 Date/POSIXct,仍解析不了则为 NA
}

# 将 FDA_DT 解析成日期,并构造排序用的“安全键”(NA 视为最小值)
demo <- demo %>%
  mutate(
    fda_dt_parsed = parse_fda_dt_safe(fda_dt),             # 解析 FDA_DT
    fda_dt_key    = if_else(is.na(fda_dt_parsed),
                            as.Date("1900-01-01"),         # NA 作为早期日期,以便不会“误胜”
                            as.Date(fda_dt_parsed))
  )

#7) 构造 PRIMARYID 数值键用于比较大小(无法转为数字的设为 -Inf)
# 注意:PRIMARYID 本质是数字 ID,这里用 suppressWarnings 转为数值,失败则 NA,再替换为 -Inf
demo <- demo %>%
  mutate(
    primaryid_num = suppressWarnings(as.double(primaryid)),
    primaryid_key = if_else(is.na(primaryid_num), -Inf, primaryid_num)
  )

#8) 严格按 FDA 规则排序 + 去重
# 先按 caseid 升序、fda_dt_key 降序、primaryid_key 降序排序,
# 然后对 caseid 执行 distinct(keep = first),即可保留组内“最大 FDA_DT;若并列取最大 PRIMARYID”的那一行
demo_sorted <- demo %>%
  arrange(caseid, desc(fda_dt_key), desc(primaryid_key))

dedup_demo <- demo_sorted %>%
  distinct(caseid, .keep_all = TRUE) %>%   # 每个 CASEID 只保留排序后的第一条(即我们定义的“最大值”)
  select(-fda_dt_key, -primaryid_key)      # 清理中间键列,保留原始字段与解析后的 fda_dt_parsed

# ---- 9) 去重后的计数(QC 对照)----
n_rows_after        <- nrow(dedup_demo)
n_caseid_after      <- n_distinct(dedup_demo$caseid)
n_primaryid_after   <- n_distinct(dedup_demo$primaryid)

# 10) 导出去重后的 DEMO
write_tsv(dedup_demo, dedup_demo_file)
cat("已输出去重后的 DEMO:", dedup_demo_file, "\n")

#11) 可选:导出被删除的 PRIMARYID(便于审计与复现)
removed_primaryids <- setdiff(unique(demo$primaryid), unique(dedup_demo$primaryid))
write_tsv(data.frame(primaryid = removed_primaryids), removed_file)
cat("已输出被移除的 PRIMARYID 列表:", removed_file, "\n")

#12) 生成 QC 报告(去重前后数量对照与比例)
qc <- tibble::tibble(
  metric = c("rows", "unique_caseid", "unique_primaryid"),
  before = c(n_rows_before, n_caseid_before, n_primaryid_before),
  after  = c(n_rows_after,  n_caseid_after,  n_primaryid_after)
) %>%
  mutate(
    reduced = before - after,                               # 绝对减少量
    reduction_ratio_percent = round(ifelse(before > 0,      # 百分比减少
                                           (before - after) / before * 100,
                                           NA_real_), 2)
  )

readr::write_csv(qc, qc_csv_file)
cat("已输出去重 QC 报告:", qc_csv_file, "\n")

#13) 额外校验(确保逻辑正确)
# 1) 确认去重后每个 CASEID 只出现一次
dup_caseid_after <- sum(duplicated(dedup_demo$caseid))
if (dup_caseid_after == 0) {
  cat("校验通过:去重后每个 CASEID 唯一。\n")
} else {
  warning("去重后仍存在重复 CASEID:", dup_caseid_after, ",请检查排序与键构造逻辑。")
}

# 2) 抽样核对:对于随机挑选的若干 caseid,确认保留的就是组内“最大 FDA_DT/PRIMARYID”
set.seed(123)
sample_caseids <- sample(unique(demo$caseid), size = min(5, n_caseid_before))
for (cid in sample_caseids) {
  grp <- demo %>% filter(caseid == cid) %>%
    arrange(desc(fda_dt_key), desc(primaryid_key))
  kept <- dedup_demo %>% filter(caseid == cid)
  # 比较第一行(应为保留行)与结果是否一致
  same_primaryid <- nrow(kept) == 1 && kept$primaryid[1] == grp$primaryid[1]
  if (!same_primaryid) {
    warning("抽检发现不一致的 CASEID:", cid, ",请人工复核。")
  }
}
cat(" DEMO 去重流程完成。\n")


# ============================================
# 用去重后的 PRIMARYID 连接其它表
# 加载必要包
library(readr)    # 读写 TSV/CSV
library(dplyr)    # 数据操作
library(stringr)  # 字符串处理
library(fs)       # 文件/目录判断与创建

# 设置路径 —— 请按实际路径确认
dedup_demo_path <- "E:/Faers/dedup/Deduped_DEMO.tsv"   # 去重后 DEMO 路径(必须存在)
cleaned_dir     <- "E:/Faers/cleaned"                 # 已应用 DeletedCases 的从表目录
final_dir       <- "E:/Faers/final_dedup_filtered"    # 输出目录(本脚本会创建)

# 确保输出目录存在
if (!dir_exists(final_dir)) dir_create(final_dir)        # 若不存在则创建

# 读取去重后的 DEMO 文件(全部按字符读入,列名统一小写)
if (!file_exists(dedup_demo_path)) stop("找不到去重后的 DEMO 文件:", dedup_demo_path)  # 若不存在则停止
dedup_demo <- read_tsv(dedup_demo_path, col_types = cols(.default = "c"), show_col_types = FALSE)  # 读入
dedup_demo <- dedup_demo %>% rename_with(tolower)      # 列名转小写,便于后续引用

# 校验 DEMO 中必须包含 primaryid 列
if (!("primaryid" %in% names(dedup_demo))) stop("去重后的 DEMO 文件缺少 'primaryid' 列。请检查。")

# 提取唯一 PRIMARYID 清单(移除 NA 和空字符串,并确保字符型)
primaryid_keep <- dedup_demo %>%
  mutate(primaryid = str_trim(primaryid)) %>%         # 去掉首尾空白
  filter(!is.na(primaryid) & primaryid != "") %>%     # 过滤空/NA
  distinct(primaryid) %>%                              # 唯一化
  pull(primaryid)                                      # 提取向量

# 记录 DEMO 中 PRIMARYID 数量(基准)
n_demo_ids <- length(primaryid_keep)                   # 基准数量
cat("DEMO 去重后 PRIMARYID 数量(基准):", n_demo_ids, "\n")  # 打印信息DEMO 去重后 PRIMARYID 数量(基准): 5445683

# 要处理的从表列表
child_tables <- c("DRUG", "REAC", "OUTC", "INDI", "THER", "RPSR")  # 六张表

# 初始化 QC 结果表(用于收集每张表的 before/after 指标)
qc <- tibble::tibble(
  table_name = character(),
  rows_before = integer(),
  rows_after  = integer(),
  unique_primaryid_before = integer(),
  unique_primaryid_after  = integer(),
  orphan_primaryid_before = integer(),   # 在从表里但不在 DEMO 的 primaryid 个数(过滤前)
  orphan_primaryid_after  = integer(),   # 过滤后仍孤立的个数(理论应为 0)
  na_primaryid_before     = integer(),   # primaryid 为 NA/空 的行数(过滤前)
  na_primaryid_after      = integer(),   # primaryid 为 NA/空 的行数(过滤后)
  coverage_in_demo_percent = numeric(),  # 过滤后在 DEMO 中的 unique_primaryid / n_demo_ids *100
  reduction_rows_percent   = numeric(),  # 行数下降比例(%)
  check_all_in_demo_after  = character() # PASS/FAIL
)

# 循环处理每张从表
for (tbl in child_tables) {
  
  # 构建 Cleaned_*.tsv 的路径(优先使用已经清洗过的 Cleaned 表)
  src_path <- file.path(cleaned_dir, paste0("Cleaned_", tbl, ".tsv"))
  
  # 若 Cleaned 文件不存在,发出警告并跳过该表
  if (!file_exists(src_path)) {
    warning("未找到文件:", src_path, ",已跳过 ", tbl)   # 警告并跳过
    next
  }
  
  # 读取从表(tab 分隔,全部作为字符)
  df <- read_tsv(src_path, col_types = cols(.default = "c"), show_col_types = FALSE)
  
  # 统一列名小写
  df <- df %>% rename_with(tolower)
  
  # 若没有 primaryid 列,记录警告并跳过
  if (!("primaryid" %in% names(df))) {
    warning(tbl, " 表中缺少 'primaryid' 列,跳过该表。")
    next
  }
  
  # 标准化 primaryid 列(trim)
  df <- df %>% mutate(primaryid = str_trim(primaryid))
  
  # 统计过滤前指标
  rows_before <- nrow(df)                                              # 总行数
  na_before   <- sum(is.na(df$primaryid) | df$primaryid == "")         # primaryid NA/空 的行数
  uniq_before <- length(unique(df$primaryid[!is.na(df$primaryid) & df$primaryid != ""]))  # 唯一 primaryid 数
  
  # 计算过滤前孤儿 ID(在从表中但不在 DEMO 清单)
  orphan_before <- length(setdiff(unique(df$primaryid[!is.na(df$primaryid) & df$primaryid != ""]),
                                  primaryid_keep))
  
  # 用去重后的 PRIMARYID 对从表进行过滤(只保留 demo 中存在的 primaryid)
  df_filtered <- df %>% filter(primaryid %in% primaryid_keep)
  
  # 统计过滤后指标
  rows_after <- nrow(df_filtered)                                      # 过滤后行数
  na_after   <- sum(is.na(df_filtered$primaryid) | df_filtered$primaryid == "")  # 过滤后 NA/空
  uniq_after <- length(unique(df_filtered$primaryid[!is.na(df_filtered$primaryid) & df_filtered$primaryid != ""])) # 唯一 primaryid 数
  orphan_after <- length(setdiff(unique(df_filtered$primaryid[!is.na(df_filtered$primaryid) & df_filtered$primaryid != ""]),
                                 primaryid_keep))  # 理论应该为 0
  
  # 计算覆盖率与行数减少百分比
  coverage <- if (n_demo_ids > 0) round(uniq_after / n_demo_ids * 100, 4) else NA_real_
  reduc_rows_pct <- if (rows_before > 0) round((rows_before - rows_after) / rows_before * 100, 4) else NA_real_
  pass_flag <- ifelse(orphan_after == 0, "PASS", "FAIL")                 # 检查过滤后是否还有孤儿
  
  # 将过滤后的从表写出到 final_dir
  out_file <- file.path(final_dir, paste0("Final_", tbl, ".tsv"))      # 输出文件名
  write_tsv(df_filtered, out_file)                                     # 写出(tab 分隔)
  cat("已输出:", out_file, " (rows_after=", rows_after, ")\n", sep = "")  # 打印输出信息
  
  # 若存在过滤前的孤儿 ID,写出示例文件供人工检查(最多写 200 个示例)
  if (orphan_before > 0) {
    orphan_ids_before <- setdiff(unique(df$primaryid[!is.na(df$primaryid) & df$primaryid != ""]),
                                 primaryid_keep)
    write_tsv(data.frame(primaryid = head(orphan_ids_before, 200)),
              file.path(final_dir, paste0("OrphanIDs_before_", tbl, ".tsv")))
  }
  
  # 若过滤后仍出现孤儿(理论不应发生),写出示例文件并发出警告
  if (orphan_after > 0) {
    orphan_ids_after <- setdiff(unique(df_filtered$primaryid[!is.na(df_filtered$primaryid) & df_filtered$primaryid != ""]),
                                primaryid_keep)
    write_tsv(data.frame(primaryid = head(orphan_ids_after, 200)),
              file.path(final_dir, paste0("OrphanIDs_after_", tbl, ".tsv")))
    warning("表 ", tbl, " 过滤后仍存在不在 DEMO 的 PRIMARYID(示例已写出)。请检查。")
  }
  
  # 将这张表的 QC 指标追加到 qc 表
  qc <- bind_rows(qc, tibble::tibble(
    table_name = tbl,
    rows_before = rows_before,
    rows_after  = rows_after,
    unique_primaryid_before = uniq_before,
    unique_primaryid_after  = uniq_after,
    orphan_primaryid_before = orphan_before,
    orphan_primaryid_after  = orphan_after,
    na_primaryid_before     = na_before,
    na_primaryid_after      = na_after,
    coverage_in_demo_percent = coverage,
    reduction_rows_percent   = reduc_rows_pct,
    check_all_in_demo_after  = pass_flag
  ))
}

# 额外:把 DEMO 自身作为基准行追加到 QC 报告(可选,但有助于查看对照)
qc_demo <- tibble::tibble(
  table_name = "DEMO_dedup_base",
  rows_before = nrow(dedup_demo),
  rows_after  = nrow(dedup_demo),
  unique_primaryid_before = length(unique(dedup_demo$primaryid)),
  unique_primaryid_after  = length(unique(dedup_demo$primaryid)),
  orphan_primaryid_before = 0,
  orphan_primaryid_after  = 0,
  na_primaryid_before = sum(is.na(dedup_demo$primaryid) | trimws(dedup_demo$primaryid) == ""),
  na_primaryid_after  = sum(is.na(dedup_demo$primaryid) | trimws(dedup_demo$primaryid) == ""),
  coverage_in_demo_percent = 100,
  reduction_rows_percent = 0,
  check_all_in_demo_after = "PASS"
)
qc <- bind_rows(qc_demo, qc)    # 将 DEMO 行放到 QC 顶部

# 将 QC 报告写出为 CSV(CSV 便于导入 Excel / R Markdown)
qc_file <- file.path(final_dir, "Linking_Consistency_QC.csv")   # QC 报告路径
write_csv(qc, qc_file)                                         # 写出 CSV(UTF-8)
cat("一致性 QC 报告已生成:", qc_file, "\n")                     # 打印完成信息

# 最后:如果任一表标记 FAIL,则发出总提醒(但不自动中止)
if (any(qc$check_all_in_demo_after == "FAIL")) {
  warning("检测到至少一张从表在过滤后仍含不在 DEMO 的 PRIMARYID,请检查 final_dedup_filtered 目录中的 OrphanIDs_after_* 文件并排查原因。")
} else {
  cat("所有从表在过滤后均不含孤儿 PRIMARYID,一致性验证通过。\n")
}