Commit 42a0a18c authored by Data Governance Dev's avatar Data Governance Dev

feat(standards): 单位/法人/经办人/对公账户 校验 12 项 indicator (3 批合并提交)

=== 第一批:单位基本信息 7 项 ===
* IND-012-a 单位名称 格式(length [4,100])
* IND-013-a 单位类型 值域(GB/T 12402-2017)
* IND-013-b 经济类型 值域(国统字〔2011〕86 号)
* IND-013-c 行业代码 格式(GB/T 4754-2017)
* IND-014-a 单位设立日期(GB/T 7408)
* IND-014-b 启缴年月(GB/T 7408)
* IND-015-a 发薪日 值域

=== 第二批:对公账户 + 老代码兼容 3 项 ===
* IND-002-c USCC 老代码兼容(GB 32100-2015 附录 A.1 + GB/T 12403-1990 MOD 11)
* IND-016-a CNAPS 联行号 格式(12 位 + 行别字典)
* IND-016-b 银行账号 格式(12~22 位 + 16~19 位 Luhn)

=== 第三批:跨表/跨字段 2 项 ===
* IND-901 单位名 全局唯一性(单字段 GROUP BY HAVING)
* IND-902 账户名 一致性(同行双字段配对 + 容错归一化)

=== 配套 ===
* 11 个已有 indicator 补 comment_keywords 让法人/经办人/单位字段自动匹配
  - ind_003a_mobile / 005a_fixed_phone / 006a_address / 006b_postal_code
  - ind_101_id_type / 201_id_card / 202_passport / 203_hkmacao / 204_foreign
  - ind_401_name_charset / 402_name_length
* orchestrator _derive_target + special_checks 加 12 段描述
* analysis_tree.json 新增 2 个分组:
  - business_unit(IND-012~15)
  - business_unit_bank(IND-016 + 901/902)
  - standards 组加 IND-002-c(老代码兼容归入 USCC 体系)
* step7 dispatch 扩展为 4 类执行路径:
  - 单值 / 跨字段 / 跨表聚合 / 跨表单字段 GROUP BY
  - 新增 _run_round3_global_uniqueness_one / _run_round3_cross_field_one
  - run_step7_for_indicator if/elif 链加 IND-901/902 分支
* 2 个新 SQL 模板:
  - sample_field_groups_single.sql(IND-901)
  - sample_field_pairs_two_text.sql(IND-902)
* WORKLOG.md 加 2 段工作记录(含 GB/T 12403 字符集修正、GB/T 4754 位数修正等关键技术细节)

验证:orchestrator._STEPS 从 36 → 48 个 step(+12)
parent b783f6ff
......@@ -2,6 +2,146 @@
> 任务做完一次记一次。最近的在最上面。
## 2026-08-10 · 单位对公账户 字段规范(第二批 3 项 + 第三批 2 项 indicator)
### 用户原话
> 开始分批做吧
> (前 9 项里 1, 6, 7, 8, 9 中剩余 + IND-901/IND-902)
第二批发:USCC 老代码兼容 + 联行号 + 银行账号(单字段 3 项)
第三批发:单位名跨表唯一性 + 账户名一致性(跨表/跨字段 2 项)
### 改动总览
#### 1. 5 个新 indicator
| ID | 名称 | 类型 | 校验要点 |
|---|---|---|---|
| IND-002-c | USCC 老代码兼容 | 单字段 | 9 位老代码 → 18 位新 USCC 推算(GB 32100-2015 附录 A.1) |
| IND-016-a | CNAPS 联行号 | 单字段 | 12 位数字 + 前 3 位主流行别字典 |
| IND-016-b | 银行账号 | 单字段 | 12~22 位 + 16~19 位 Luhn |
| IND-901 | 单位名全局唯一性 | 跨表 GROUP BY | 单字段 GROUP BY HAVING 检出重复 |
| IND-902 | 账户名一致性 | 跨字段 | (账户名, 参考字段) 同行配对 + 容错比对 |
#### 2. IND-002-c 关键技术细节(与第一版不同)
##### 老代码字符集是 35 字符,不是 31
GB/T 12405-1990 老代码字符集 = `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ`(含 I/O/Z/S/V)。
新 USCC 字符集 = `0123456789ABCDEFGHJKLMNPQRTUWXY`(31 字符,不含 I/O/Z/S/V)。
混用会导致校验位算法失效。
##### GB/T 12403-1990 校验位 ≠ GB 32100 校验位
- 老代码:MOD 11 + 固定映射表 `[1, 0, X, 9, 8, 7, 6, 5, 4, 3, 2]`
- 新 USCC(IND-002-b):MOD 31 + 字符集倒索引
- 权重都是 `[3, 7, 9, 10, 5, 8, 4, 2]`
##### 附录 A.1 转换映射(手算表格)
| 老代码首位 | 含义 | 新 USCC (部门 + 类别) |
|---|---|---|
| 1 | 机关 | 11 |
| 2 | 事业单位 | 12 |
| 3 | 企业 | 91 |
| 4 | 社会团体 | 51 |
| 5 / 9 | 其他 / 个体工商户 | 99 |
新 USCC 后 15 位:行政区划(默认 110000)+ 老代码第 2-9 位 + '0' + 重新计算的校验位。
#### 3. step7 dispatch 扩展(4 类执行路径)
[web/core/step_impl/step7_standards.py:1-12](web/core/step_impl/step7_standards.py#L1-L12) 注释:
- 单值类(22 个)
- 跨字段类(IND-301 / IND-902)
- 跨表聚合类(IND-302)
- 跨表单字段 GROUP BY(IND-901)
新增 2 个 dispatch 函数:
- [_run_round3_global_uniqueness_one](web/core/step_impl/step7_standards.py#L1019-L1144) —— IND-901 单字段 GROUP BY HAVING
- [_run_round3_cross_field_one](web/core/step_impl/step7_standards.py#L1146-L1310) —— IND-902 同行双字段一致性(容错归一化比对)
[web/core/step_impl/step7_standards.py:1313-1316](web/core/step_impl/step7_standards.py#L1313-L1316) `run_step7_for_indicator` if/elif 链扩展。
#### 4. 2 个新 SQL 模板
- [web/sql/standards/sample_field_groups_single.sql](web/sql/standards/sample_field_groups_single.sql) —— 单字段 GROUP BY HAVING
- [web/sql/standards/sample_field_pairs_two_text.sql](web/sql/standards/sample_field_pairs_two_text.sql) —— 双文本字段同行抽样
#### 5. 注册
- [web/core/orchestrator.py:300-310](web/core/orchestrator.py#L300-L310) `_derive_target()` 加 IND-901/902
- [web/core/orchestrator.py:382-440](web/core/orchestrator.py#L382-L440) `special_checks` 加 5 段业务背景
- [web/configs/analysis_tree.json](web/configs/analysis_tree.json)
- standards 组加 `std_ind_002_c`(老代码兼容归入 USCC 体系)
- 新增 `business_unit_bank` 组:单位对公账户 字段规范(IND-016-a/b + IND-901/902)
- 验证:`orchestrator._STEPS` 从 43 → **48** 个 step ✓
### 测试通过的关键点
- IND-002-c:手工构造合法老代码 6 类(首位 1/2/3/4/5/9),GB/T 12403-1990 MOD 11 全部通过;异常用例(首位字母 / 校验位错 / 长度非 9 或 18)正确拒绝
- IND-016-a:102 工行 / 103 农行 / 104 中行 / 105 建行 / 301 交通 等主流行别字典外识别为 warning
- IND-016-b:Luhn 自检通过 13/13 用例(含 19 位 / 16 位 / 12 位 / 清洗空格横线)
- IND-902 validate_rows:「北京 XX 有限公司」 vs 「北京XX有限公司」 → 归一化后匹配 = 一致(容错);「张三」 vs 「李四」 → 不一致 violation
### 已知限制(后续批次可考虑)
- **IND-901 跨多张表同名**:本 indicator 当前只做"同表层" GROUP BY;跨表同名(疑似同集团/子公司)需要 UNION ALL 后再 GROUP BY,留待后续
- **IND-902 配对启发式**:当前按字段名白名单 + 注释命中 找配对;如果业务系统单位名称和账户名字段都不符合白名单,需手动配置
- **IND-016-a 校验位**:央行未公开权重表,仅做格式 + 行别字典校验;如需位校验,需接入 PBOC 内部接口(银行系才可用)
## 2026-08-10 · 单位/法人/经办人 字段规范(第一批 7 项 indicator)
### 用户原话
> 检查下面这些检验有没有实现:
> 1. 统一社会信用代码(重中之重)
> 2. 单位名称
> 3. 单位类型/经济类型/行业代码
> 4. 单位设立日期/启缴年月
> 5. 发薪日
> 6. 法人信息
> 7. 经办人信息
> 8. 单位对公账户
> 9. 单位地址/邮编/联系电话
> 开始分批做吧
第一批发:基础单位信息(5+2 = 7 个 indicator)+ 给已有 indicator 补 comment_keywords 让法人/经办人/单位字段匹配上。
### 改动总览
#### 1. 7 个新 indicator
| ID | 名称 | 标准 / 来源 | 校验要点 |
|---|---|---|---|
| IND-012-a | 单位名称 格式 | 业务常识 | length ∈ [4,100] / 不全空白/符号/数字/占位符 |
| IND-013-a | 单位类型 值域 | GB/T 12402-2017 | 2 位大类 / 4 位中类 / 文字形式(公司/有限责任 等) |
| IND-013-b | 经济类型 值域 | 国统字〔2011〕86 号 | 3 位数字码(100/110/.../390)+ 文字形式 |
| IND-013-c | 行业代码 格式 | GB/T 4754-2017 | 3 位大类 / 4 位中类 / 5 位小类(门类 A~T) |
| IND-014-a | 单位设立日期 | GB/T 7408-2005 | 8 位 YYYYMMDD 或带分隔;年份 ∈ [1949, 当前] |
| IND-014-b | 启缴年月 | GB/T 7408-2005 | 6 位 YYYYMM / 8 位 YYYYMMDD;月份 ∈ [01,12] |
| IND-015-a | 发薪日 值域 | 业内惯例 | 1~31 / 99=月末;拒 0/32+/浮点/全角 |
#### 2. 给 11 个已有 indicator 补 comment_keywords(让法人/经办人/单位字段匹配上)
- [standards/ind_003a_mobile_format.py](standards/ind_003a_mobile_format.py):法人手机/经办人手机/单位手机
- [standards/ind_005a_fixed_phone_format.py](standards/ind_005a_fixed_phone_format.py):单位联系电话/经办人电话/法人电话
- [standards/ind_006a_address_format.py](standards/ind_006a_address_format.py):法人地址/经办人地址
- [standards/ind_006b_postal_code_format.py](standards/ind_006b_postal_code_format.py):单位邮编
- [standards/ind_101_id_type.py](standards/ind_101_id_type.py):法人证件类型/经办人证件类型
- [standards/ind_201_id_number_id_card.py](standards/ind_201_id_number_id_card.py):法人证件号/经办人证件号
- [standards/ind_202_id_number_passport.py](standards/ind_202_id_number_passport.py):法人护照号
- [standards/ind_203_id_number_hkmacao_residence.py](standards/ind_203_id_number_hkmacao_residence.py):法人港澳台居住证
- [standards/ind_204_id_number_foreign_residence.py](standards/ind_204_id_number_foreign_residence.py):法人永居证
- [standards/ind_401_name_charset.py](standards/ind_401_name_charset.py):法人姓名/经办人姓名
- [standards/ind_402_name_length.py](standards/ind_402_name_length.py):法人姓名/经办人姓名
#### 3. orchestrator + analysis_tree.json 注册
- [web/core/orchestrator.py:300-308](web/core/orchestrator.py#L300-L308) `_derive_target()` 新增 7 个 indicator 分支
- [web/core/orchestrator.py:382-429](web/core/orchestrator.py#L382-L429) `special_checks` dict 新增 7 段「业务背景 + 校验要点」摘要(前端 hover 展示)
- [web/configs/analysis_tree.json:88-101](web/configs/analysis_tree.json#L88-L101) 新增 `business_unit` 分组:「单位 / 法人 / 经办人 字段规范」
- 验证:`orchestrator._STEPS` 从 36 → 43 个 step
#### 4. 已知 bug 修复(IND-013-c 行业代码)
- 初版我把 GB/T 4754 编码位数搞错:错写为「4 位大类 / 5 位中类 / 6 位小类」(即字母 + 3~5 位数字)
- 实际 GB/T 4754-2017 是「3 位大类 / 4 位中类 / 5 位小类」(字母 + 2~4 位数字):
- A01(农业)/ A011(谷物种植)/ A0111(稻谷种植)
- 修正 [_REGEX](standards/ind_013c_industry_code_format.py#L57) `^([A-T])(\d{2,4})$`
- 同步去掉错误的「门类 ↔ 数字码映射」逻辑(实际没有纯数字码等价物)
### 后续批次(待办)
- **第二批**:IND-002-c 老组织机构代码 → 新 USCC 兼容转换;IND-016-a/b 联行号 + 银行账号
- **第三批**:IND-901 单位名全局唯一性(跨表 GROUP BY,参考 IND-302);IND-902 账户名一致性(跨字段)
## 2026-08-10 · 公积金领域 8 项新 indicator + 字段注释匹配
### 用户原话
......
"""IND-002-c 老组织机构代码 → 新 USCC 兼容转换(GB 32100-2015 附录 A.1)
老代码结构(GB/T 12405-2008 / GB/T 12403-1990):
- 9 位 = 1 位机构类别 + 7 位顺序码 + 1 位校验位
- 第 1 位机构类别:
1 = 机关
2 = 事业单位
3 = 企业
4 = 社会团体
5 = 其他
9 = 个体工商户
- 第 9 位校验位:GB/T 12403-1990(MOD 31 加权)
新 USCC(GB 32100-2015):
- 18 位 = 1 位部门 + 1 位类别 + 6 位行政区划 + 9 位主体标识码 + 1 位校验位
- 部门代码:
1 机构编制
5 民政
9 工商
Y 其他
- 类别代码(按部门细分):
部门 1:1 机关 / 2 事业单位
部门 5:1 社会团体
部门 9:1 企业 / 9 个体工商户 / 9 其他
转换规则(附录 A.1):
1. 老代码第 1 位查 _LEGACY_TO_DEPT_CAT → 新 USCC 前 2 位(部门 + 类别)
2. 新 USCC 第 3-8 位(行政区划)默认填 "110000"(北京,登记管理机关所在地无法追溯时的兜底)
3. 新 USCC 第 9-16 位 = 老代码第 2-9 位(8 位主体码)
4. 新 USCC 第 17 位 = "0"
5. 新 USCC 第 18 位 = 用 IND-002-b 校验位算法重新计算
indicator 行为:
- 输入 9 位老代码:
· 校验 GB/T 12403 校验位(不通过则拒绝)
· 转 18 位新 USCC(仅在 reason 中给出"建议替换为 USCC: XXXXXX")
· 字段本身 valid(无新代码可用的情况下,老代码暂保留可接受)
- 输入 18 位新 USCC:直接放过(IND-002-a/b 负责校验)
- 其他长度:invalid
样例:
✓ 59999999X(老代码 5=其他 → 部门 9 / 类别 9 / 110000 / 99999999 / 0 / ?)
✗ 599999999(老代码校验位错)/ A99999999(首位非数字)/ 12345(5 位)
"""
from __future__ import annotations
from .base import BaseStandard, ValidationResult
# ─────────────────────────────────────────────────────────────────────
# 老代码第 1 位 → 新 USCC (部门, 类别) 映射
# 参考 GB 32100-2015 附录 A.1
# ─────────────────────────────────────────────────────────────────────
_LEGACY_TO_DEPT_CAT: dict[str, tuple[str, str]] = {
"1": ("1", "1"), # 机关
"2": ("1", "2"), # 事业单位
"3": ("9", "1"), # 企业
"4": ("5", "1"), # 社会团体
"5": ("9", "9"), # 其他
"9": ("9", "9"), # 个体工商户(部分口径归入工商"其他")
}
_DEFAULT_REGION = "110000" # 北京(无法追溯登记管理机关所在地时的兜底)
# ─────────────────────────────────────────────────────────────────────
# GB/T 12403-1990 校验位算法(MOD 11 + 查表映射)
# 老代码字符集 = 35 字符(0-9 + A-Z),与新 USCC 的 31 字符(含 I/O/Z/S/V)
# ─────────────────────────────────────────────────────────────────────
_LEGACY_WEIGHTS = [3, 7, 9, 10, 5, 8, 4, 2] # 8 个权重对应 8 位本体码
_LEGACY_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 35 字符(含 I/O/Z/S/V)
_LEGACY_CHECK_MAP = ["1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2"]
def _legacy_mod11_check(legacy: str) -> bool:
"""GB/T 12403-1990 校验位:前 8 位 × 权重 mod 11 → 查 _LEGACY_CHECK_MAP[r]。
与 IND-002-b 的 MOD 31-3 不同;这是 MOD 11 + 固定映射表。
"""
if len(legacy) != 9 or legacy[0] not in "123459":
return False
body, check = legacy[:8], legacy[8]
total = 0
for i, c in enumerate(body):
if c not in _LEGACY_CHARSET:
return False
val = _LEGACY_CHARSET.index(c)
total += val * _LEGACY_WEIGHTS[i]
mod = total % 11
expected = _LEGACY_CHECK_MAP[mod]
return check == expected
# ─────────────────────────────────────────────────────────────────────
# 新 USCC 校验位(复用 IND-002-b 的 MOD 31-3 算法)
# ─────────────────────────────────────────────────────────────────────
_USCC_CHARSET = "0123456789ABCDEFGHJKLMNPQRTUWXY"
_CHAR_MAP = {c: i for i, c in enumerate(_USCC_CHARSET)}
def _uscc_checksum(seventeen: str) -> str:
total = 0
for i, c in enumerate(seventeen):
if c not in _CHAR_MAP:
raise ValueError(f"字符 {c!r} 不在 GB 32100 字符集内")
total += _CHAR_MAP[c] * (3 ** i)
idx = (31 - total % 31) % 31
return _USCC_CHARSET[idx]
class UsccLegacyCompatIndicator(BaseStandard):
standard_id = "IND-002-c"
standard_name = "GB 32100-2015 老代码兼容转换"
applies_to_fields = [
"uscc", "credit_code", "social_credit_code", "unified_social_credit_code",
# 老代码字段名兼容(部分业务系统保留旧字段)
"org_code", "organization_code", "zzjgdm", "jgdm",
]
description = (
"9 位老组织机构代码 → 18 位新 USCC 兼容转换(GB 32100-2015 附录 A.1)。"
"9 位老代码须通过 GB/T 12403-1990 校验位(权重 [3,7,9,10,5,8,4,2] mod 11 → 0-9,X)。"
"18 位新代码直接放过(由 IND-002-a/b 校验)。"
)
group = "国标字段规范"
severity = "warning"
def _convert(self, legacy: str) -> str:
"""老代码 → 新 USCC 18 位(GB 32100-2015 附录 A.1)。"""
dept_cat = _LEGACY_TO_DEPT_CAT[legacy[0]]
head = dept_cat[0] + dept_cat[1]
body = _DEFAULT_REGION + legacy[1:] + "0" # 6 位区划 + 8 位老代码 + 1 位 = 15 位;17 位 = head + 15 位
seventeen = head + body # 2 + 15 = 17 位
return seventeen + _uscc_checksum(seventeen)
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
# 18 位新 USCC —— 放行,由 IND-002-a/b 校验
if len(v) == 18:
return ValidationResult(True, "", value, "", self.standard_id)
# 9 位老代码 —— 校验 + 转换建议
if len(v) == 9:
if v[0] not in _LEGACY_TO_DEPT_CAT:
return ValidationResult(
False, "", value,
f"老代码首位 {v[0]!r} 不在 GB/T 12405 机构类别枚举 (1/2/3/4/5/9)",
self.standard_id,
)
if not _legacy_mod11_check(v):
return ValidationResult(
False, "", value,
f"老代码 {v!r} 校验位错误(GB/T 12403-1990 MOD 11)",
self.standard_id,
)
new_uscc = self._convert(v)
return ValidationResult(
True, "", value,
f"老代码格式正确;建议替换为新 USCC: {new_uscc}",
self.standard_id,
)
return ValidationResult(
False, "", value,
f"长度 {len(v)} 既不是 9 位老代码也不是 18 位新 USCC",
self.standard_id,
)
\ No newline at end of file
......@@ -24,7 +24,9 @@ class MobileFormatIndicator(BaseStandard):
"contact_phone", "customer_mobile", "legal_person_mobile",
"notify_phone", "maintainer_phone", "manager_phone", "receiver_phone",
]
comment_keywords = ["手机号码", "手机号", "联系手机", "联系电话"]
comment_keywords = ["手机号码", "手机号", "联系手机", "联系电话",
"法人手机", "法人联系电话", "经办人手机", "经办人联系电话",
"单位手机", "单位联系人手机"]
description = (
"11 位;以 1 开头,第 2 位 3-9;自动清洗 +86 / 空格 / 横线;"
"**不可全 0**(如 00000000000 / 00000123456 等号段残缺值视为无效)"
......
......@@ -32,6 +32,7 @@ class FixedPhoneFormatIndicator(BaseStandard):
comment_keywords = [
"固定电话", "办公电话", "公司电话", "单位电话", "工作电话",
"联系电话", "传真", "座机",
"单位联系电话", "经办人电话", "法人电话",
]
description = (
"0 开头区号(3~4 位)+ 可选「- / 空格」分隔 + 7~8 位号码;"
......
......@@ -28,6 +28,7 @@ class AddressFormatIndicator(BaseStandard):
"通讯地址", "联系地址", "户籍地址", "居住地址", "住址", "现住址",
"工作地址", "单位地址", "公司地址", "注册地址", "办公地址",
"通讯地址", "收件地址", "邮寄地址", "送达地址", "通讯地点",
"法人地址", "经办人地址",
]
description = (
"trim 后 4 ≤ len ≤ 200;不全空白 / 不全符号 / 不全数字;"
......
......@@ -20,7 +20,7 @@ class PostalCodeFormatIndicator(BaseStandard):
applies_to_fields = [
"postal_code", "postcode", "zip", "zip_code", "zipcode",
]
comment_keywords = ["邮编", "邮政编码"]
comment_keywords = ["邮编", "邮政编码", "单位邮编"]
description = "6 位数字(GB/T 23705-2009);前 2 位对应省级区域(仅校验格式,不查存在)"
group = "通用"
severity = "warning"
......
"""IND-012-a 单位名称 格式
参考:
- 国家市场监管总局《企业名称登记管理规定》及《企业名称登记管理实施办法》
- 业内惯例:
- 长度 ∈ [4, 100] 字符(trim 后)
- 不全空白 / 不全符号 / 不全数字
- 不为占位符("无"/"暂无"/"未知"/"TBD")
- 不含控制字符 / Emoji
字符集要求(比个人姓名宽松):
- 汉字(CJK)
- ASCII 字母(拼音 / 英文公司名)
- 数字(如"三六零" "TCL" "华润 1234 物业")
- 常见符号:半角圆括号 () / 全角圆括号 ()/ 空格 / · /
/ - / & 等
- 不允许:HTML 标签 / 转义符 / 控制字符 / 表情
不做:
- 注册号 / 统一社会信用代码 提取(用 IND-002-a)
- 全局唯一性(见 IND-901 —— 本 indicator 仅做单值格式)
- 行政区划 / 行业 / 组织形式提取(LLM 任务)
"""
from __future__ import annotations
import re
from .base import BaseStandard, ValidationResult
class CompanyNameFormatIndicator(BaseStandard):
standard_id = "IND-012-a"
standard_name = "单位名称 格式"
applies_to_fields = [
"company_name", "corp_name", "enterprise_name", "unit_name",
"dwname", "dwmc", "gsmc",
]
comment_keywords = [
"单位名称", "公司名称", "企业名称", "机构名称",
"单位名", "公司名", "企业名", "机构名",
"缴存单位名称", "开户单位名称", "参保单位名称",
]
description = (
"长度 ∈ [4, 100] 字符;不全空白 / 不全符号 / 不全数字;"
"不接受占位符(无 / 暂无 / 未知 / TBD);"
"字符集:汉字 + 字母 + 数字 + 常见括号 / 间隔符;不查全局唯一性"
)
group = "通用"
severity = "warning"
_MIN_LEN = 4
_MAX_LEN = 100
@staticmethod
def _is_han(c: str) -> bool:
return "一" <= c <= "鿿" or "㐀" <= c <= "䶿"
@staticmethod
def _has_printable(v: str) -> bool:
return any((not c.isspace()) and c.isprintable() for c in v)
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
if not v:
return ValidationResult(False, "", value, "全空白", self.standard_id)
if not self._has_printable(v):
return ValidationResult(False, "", value, "无可识别字符", self.standard_id)
if len(v) < self._MIN_LEN:
return ValidationResult(
False, "", value,
f"trim 后长度 {len(v)} < {self._MIN_LEN}(疑似行政区划 / 组织形式不全)",
self.standard_id,
)
if len(v) > self._MAX_LEN:
return ValidationResult(
False, "", value,
f"长度 {len(v)} > {self._MAX_LEN}(疑似录入异常 / 字段定义过短)",
self.standard_id,
)
# 占位符
bad_markers = {"无", "暂无", "未知", "TBD", "tbd", "null", "NULL", "/", "-"}
if v in bad_markers:
return ValidationResult(False, "", value, f"录入占位符 {v!r}", self.standard_id)
# 全数字(且无汉字 + 无字母)→ 异常
has_chinese = any(self._is_han(c) for c in v)
has_alpha = any(("a" <= c <= "z") or ("A" <= c <= "Z") for c in v)
if not has_chinese and not has_alpha:
return ValidationResult(False, "", value, "全数字 / 全符号且无汉字 / 字母", self.standard_id)
# 控制字符 / 异常字符
bad = [c for c in v if (not c.isprintable()) or c in ("\x00", "\x0a", "\x0d", "\x09")]
if bad:
return ValidationResult(
False, "", value,
f"含控制字符或不可打印字符 {''.join(bad[:5])!r}",
self.standard_id,
)
# 至少要有一个汉字或一个字母(防止纯符号 + 数字混杂)
if not (has_chinese or has_alpha):
return ValidationResult(False, "", value, "缺少汉字或字母", self.standard_id)
return ValidationResult(True, "", value, "", self.standard_id)
\ No newline at end of file
"""IND-013-a 单位类型 值域
参考:
- GB/T 12402-2008《单位类别分类与代码》(已被替代但口径仍广泛沿用)
- GB/T 12402-2017《单位类别分类与代码》新版(合并 单位类型 + 经济类型)
- 国家市场监管总局《市场主体类型代码表》
- 各业务系统常见混用 2 位 / 4 位数字码 + 文字形式
本 indicator 接受 3 套口径并集(数字码 + 文字形式),不强求统一:
- 2 位 / 4 位数字码:
10 / 11 / 12 / 13 / 14 / 15 / 20 / 21 / 22 / 23 / 24 / 30 / 99
1000 / 1100 / 1110 / 1120 / 1190 / 1200 / 2000 / 3000 / 4000 / 5000 / 9000
- 文字(兼容录入):
公司 / 有限责任公司 / 股份有限公司 / 国有企业 / 集体企业 /
私营企业 / 外资企业 / 中外合资 / 中外合作 / 合伙企业 /
个人独资企业 / 个体工商户 / 农民专业合作社 / 事业单位 /
机关单位 / 社会团体 / 民办非企业单位 / 基金会 / 其他
不做:
- 数字码与文字的双向换算(不同地区不一致)
- 行业 / 经济类型校验(IND-013-c / IND-013-b 各做各的)
样例:
✓ 11 / 1100 / 公司 / 有限责任公司 / 事业单位 / 个体工商户
✗ 09 / 9999 / 不存在的类型 / 工厂(业务上模糊)
"""
from __future__ import annotations
from .base import BaseStandard, ValidationResult
class UnitTypeEnumIndicator(BaseStandard):
standard_id = "IND-013-a"
standard_name = "GB/T 12402 单位类型 值域"
applies_to_fields = [
"unit_type", "corp_type", "company_type", "enterprise_type",
"dwlx", "dwlb", "qylx",
]
comment_keywords = [
"单位类型", "单位类别", "公司类型", "企业类型",
"单位性质", "机构类型", "市场主体类型",
"单位代码", "机构代码",
]
description = (
"枚举值 ∈ {10 企业法人 / 11 有限责任公司 / 12 股份有限公司 / 13 个人独资 / "
"14 合伙 / 15 个体工商户 / 20 事业单位 / 21 机关 / 22 社会团体 / "
"23 民办非企业 / 24 基金会 / 30 其他};"
"接受 2 位 / 4 位数字码 + 文字形式(公司 / 有限责任公司 / 事业单位 ...);"
"**不可自定义**(业务上无法分类)"
)
group = "通用"
severity = "warning"
# 2 位数字码
_ALLOWED_2 = {
"10", "11", "12", "13", "14", "15",
"20", "21", "22", "23", "24", "30", "99",
}
# 4 位数字码(市场监管总局 / 部分省市扩展)
_ALLOWED_4 = {
"1000", # 公司
"1100", # 有限责任公司
"1110", # 自然人独资
"1120", # 法人独资
"1190", # 其他有限责任公司
"1200", # 股份有限公司
"1210", "1290",
"2000", # 合伙企业
"2100", "2200", "2300", "2900",
"3000", # 个人独资企业
"4000", # 个体工商户
"5000", # 农民专业合作社
"9000", # 其他
}
# 文字形式(兼容录入)
_ALLOWED_TEXT = {
# 公司类
"公司", "总公司", "子公司", "分公司", "集团公司",
"有限责任公司", "有限公司", "股份有限责任公司",
"股份有限公司", "股份公司",
"国有企业", "国有", "国营",
"集体企业", "集体", "乡镇企业",
"私营企业", "民营", "民营企业", "私人企业",
"外资企业", "外商独资", "外商独资企业", "独资企业",
"合资企业", "中外合资", "中外合作", "中外合作企业",
"联营企业", "联营",
"合伙企业", "普通合伙", "有限合伙",
"个人独资企业", "个人独资", "个体",
"个体工商户", "个体户", "个体经营",
"农民专业合作社", "合作社",
"其他企业", "其他",
# 事业 / 机关 / 社团
"事业单位", "事业",
"机关单位", "机关", "行政机关", "党政机关",
"社会团体", "社团", "协会", "学会", "商会",
"民办非企业单位", "民非", "民办非企业",
"基金会",
"居委会", "村委会", "社区", "村集体",
}
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
if v in self._ALLOWED_2 or v in self._ALLOWED_4 or v in self._ALLOWED_TEXT:
return ValidationResult(True, "", value, "", self.standard_id)
return ValidationResult(
False, "", value,
f"单位类型 {v!r} 不在白名单(GB/T 12402 + 市场监管总局市场主体类型码)",
self.standard_id,
)
\ No newline at end of file
"""IND-013-b 经济类型 值域
参考:
- 国家统计局《关于划分企业登记注册类型的规定》(国统字〔2011〕86 号),
已更新至 2017 / 2020 版
- 业内常见 3 位数字码:
100 内资企业
110 国有企业
120 集体企业
130 股份合作企业
140 联营企业
150 有限责任公司
160 股份有限公司
170 私营企业
190 其他内资
200 港澳台投资企业
210 与港澳台合资经营
220 与港澳台合作经营
230 港澳台独资
240 港澳台投资股份有限公司
290 其他港澳台投资
300 外商投资企业
310 中外合资经营
320 中外合作经营
330 外资企业
340 外商投资股份有限公司
390 其他外商投资
- 也接受文字形式(兼容录入)
样例:
✓ 110 / 150 / 210 / 330 / 国有企业 / 有限责任公司 / 中外合资
✗ 111 / 199 / 400 / 工厂(业务上模糊)
"""
from __future__ import annotations
from .base import BaseStandard, ValidationResult
class EconomicTypeEnumIndicator(BaseStandard):
standard_id = "IND-013-b"
standard_name = "国统字〔2011〕86 号 经济类型 值域"
applies_to_fields = [
"economic_type", "econ_type", "jjlx", "jjxz",
]
comment_keywords = [
"经济类型", "经济性质", "登记注册类型", "企业注册类型",
"企业经济类型", "单位经济类型",
]
description = (
"枚举值 ∈ {100 内资 / 200 港澳台 / 300 外资} + "
"子项(110 国有 / 120 集体 / 130 股份合作 / 140 联营 / 150 有限责任 / "
"160 股份 / 170 私营 / 210 港澳合资 / 220 港澳合作 / 230 港澳独资 / "
"240 港澳台股份 / 310 中外合资 / 320 中外合作 / 330 外资 / 340 外商股份}; "
"接受 3 位数字码 + 文字形式"
)
group = "通用"
severity = "warning"
# 3 位数字码
_ALLOWED = {
# 内资
"100", "110", "120", "130", "140", "150", "160", "170", "190",
# 港澳台
"200", "210", "220", "230", "240", "290",
# 外资
"300", "310", "320", "330", "340", "390",
}
# 文字形式
_ALLOWED_TEXT = {
# 大类
"内资", "内资企业", "港澳台投资", "港澳台", "外商投资", "外资",
# 内资子类
"国有", "国有企业", "国营",
"集体", "集体企业",
"股份合作", "股份合作企业",
"联营", "联营企业",
"有限责任", "有限责任公司",
"股份", "股份制", "股份有限公司", "股份公司",
"私营", "私营企业", "民营", "民营企业",
# 港澳台子类
"港澳台合资", "港澳合资", "与港澳台合资经营",
"港澳台合作", "港澳合作", "与港澳台合作经营",
"港澳台独资", "港澳独资",
"港澳台投资股份", "港澳台投资股份有限公司",
# 外资子类
"中外合资", "中外合资经营",
"中外合作", "中外合作经营",
"外资企业", "外商独资",
"外商投资股份", "外商投资股份有限公司",
# 兜底
"其他", "其他内资", "其他港澳台投资", "其他外商投资",
}
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
if v in self._ALLOWED or v in self._ALLOWED_TEXT:
return ValidationResult(True, "", value, "", self.standard_id)
return ValidationResult(
False, "", value,
f"经济类型 {v!r} 不在白名单(国统字〔2011〕86 号)",
self.standard_id,
)
\ No newline at end of file
"""IND-013-c 行业代码 格式
参考:
- GB/T 4754-2017《国民经济行业分类》
- 门类 20 个:A~T(单字母)
- 大类:门类字母 + 2 位数字(3 位,如 A01 = 农业)
- 中类:门类字母 + 3 位数字(4 位,如 A011 = 谷物种植)
- 小类:门类字母 + 4 位数字(5 位,如 A0111 = 稻谷种植)
- 完整版 ~138 大类 + ~453 中类 + ~1475 小类
- 业务系统常以"中类 4 位"或"小类 5 位"录入(最少也是大类 3 位)
样例:
✓ A01(大类)/ A011(中类)/ A0111(小类)/ B06 / K70 / K7010
✗ Z99(门类超界)/ a011(小写)/ A1234(小类后再多 1 位)/ 0111(纯数字)
不做:
- 行业存在性校验(GB/T 4754 完整表 ~2000 条,加载不划算;
留待后续 IND-013-d 做精细化)
"""
from __future__ import annotations
import re
from .base import BaseStandard, ValidationResult
# 20 个门类
_VALID_CATEGORIES = set("ABCDEFGHIJKLMNOPQRST")
class IndustryCodeFormatIndicator(BaseStandard):
standard_id = "IND-013-c"
standard_name = "GB/T 4754 行业代码 格式"
applies_to_fields = [
"industry_code", "industry", "trade_code", "hy_code", "hybm",
"industry_category",
]
comment_keywords = [
"行业代码", "行业类别", "行业分类", "国民经济行业",
"所属行业", "行业大类", "行业小类",
"GB/T 4754", "行业",
]
description = (
"门类字母(A~T) + 2~4 位数字(3/4/5 位总数):"
"3 位=大类 / 4 位=中类 / 5 位=小类(GB/T 4754-2017);"
"本 indicator 仅校验格式,不查行业存在性(完整表 ~2000 条)"
)
group = "通用"
severity = "warning"
# 3 位大类 / 4 位中类 / 5 位小类
_REGEX = re.compile(r"^([A-T])(\d{2,4})$")
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
m = self._REGEX.match(v)
if not m:
return ValidationResult(
False, "", value,
f"行业代码 {v!r} 不符合「门类字母 A~T + 2~4 位数字」格式(GB/T 4754-2017)",
self.standard_id,
)
digits = m.group(2)
# GB/T 4754 大类编码最小从 X01 起,不存在 X00 大类
if digits[:2] == "00":
return ValidationResult(
False, "", value,
f"行业代码 {v!r} 数字段以 00 开头(GB/T 4754 实际最小从 X01 起)",
self.standard_id,
)
return ValidationResult(True, "", value, "", self.standard_id)
\ No newline at end of file
"""IND-014-a 单位设立日期 格式
参考:
- GB/T 7408-2005《数据元和交换格式 信息交换 日期和时间表示法》
- 业务常见 3 种录入格式:
1. YYYYMMDD 8 位数字(标准 ISO 8601 basic)
2. YYYY-MM-DD / YYYY/MM/DD / YYYY.MM.DD(分隔形式)
3. YYYYMM 6 位数字(仅月精度,少数系统)
- 年份范围:1949 ~ 当前年(设立日期不应早于新中国成立日;不应晚于当前)
- 月份 ∈ [01,12];日期 ∈ [01, 28/29/30/31](含闰年 2-29 校验)
样例:
✓ 19850321 / 1985-03-21 / 1985/03/21 / 198503 / 202012
✗ 19480321 / 20300821 / 19851321 / 19850230
"""
from __future__ import annotations
import re
from datetime import datetime
from .base import BaseStandard, ValidationResult
class CompanyEstablishmentDateIndicator(BaseStandard):
standard_id = "IND-014-a"
standard_name = "GB/T 7408 单位设立日期 格式"
applies_to_fields = [
"establishment_date", "establish_date", "founded_date", "set_up_date",
"registration_date", "reg_date", "qyslrq", "slrq",
]
comment_keywords = [
"单位设立日期", "设立日期", "成立日期", "成立时间", "注册日期",
"登记日期", "开业日期", "公司成立日期", "企业成立日期",
"成立年月",
]
description = (
"YYYYMMDD 或 YYYY-MM-DD / YYYY/MM/DD / YYYY.MM.DD;"
"也接受 6 位 YYYYMM(仅月精度);"
"年份 ∈ [1949, 当前年](早于 1949 视为异常,> 当前年视为未来)"
)
group = "通用"
severity = "warning"
_REGEX_8 = re.compile(r"^(\d{4})(\d{2})(\d{2})$")
_REGEX_8_S = re.compile(r"^(\d{4})[\-./](\d{2})[\-./](\d{2})$")
_REGEX_6 = re.compile(r"^(\d{4})(\d{2})$")
@staticmethod
def _is_valid_date(year: int, month: int, day: int) -> bool:
"""含闰年 2-29 校验。"""
if month < 1 or month > 12 or day < 1 or day > 31:
return False
try:
datetime(year, month, day)
return True
except ValueError:
return False
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
# 6 位 YYYYMM
m6 = self._REGEX_6.match(v)
if m6 and not self._REGEX_8.match(v):
year, month = int(m6.group(1)), int(m6.group(2))
if not (1949 <= year <= datetime.now().year):
return ValidationResult(
False, "", value,
f"年份 {year} ∉ [1949, {datetime.now().year}]",
self.standard_id,
)
if not (1 <= month <= 12):
return ValidationResult(False, "", value, f"月份 {month} ∉ [01,12]", self.standard_id)
return ValidationResult(True, "", value, "", self.standard_id)
# 8 位 YYYYMMDD 或 8 位带分隔
m8 = self._REGEX_8.match(v)
m8s = self._REGEX_8_S.match(v)
if m8:
y, mo, d = int(m8.group(1)), int(m8.group(2)), int(m8.group(3))
elif m8s:
y, mo, d = int(m8s.group(1)), int(m8s.group(2)), int(m8s.group(3))
else:
return ValidationResult(
False, "", value,
"不符合 8 位 YYYYMMDD 或 YYYY-MM-DD / YYYY/MM/DD / YYYY.MM.DD 或 6 位 YYYYMM",
self.standard_id,
)
if not self._is_valid_date(y, mo, d):
return ValidationResult(
False, "", value,
f"日期 {y}-{mo:02d}-{d:02d} 不合法(含闰年 2-29 校验)",
self.standard_id,
)
if not (1949 <= y <= datetime.now().year):
return ValidationResult(
False, "", value,
f"年份 {y} ∉ [1949, {datetime.now().year}]",
self.standard_id,
)
return ValidationResult(True, "", value, "", self.standard_id)
\ No newline at end of file
"""IND-014-b 启缴年月 格式
公积金 / 社保业务的「单位启缴年月」:单位开始为职工缴存公积金的月份。
参考:
- GB/T 7408-2005《数据元和交换格式 信息交换 日期和时间表示法》
- 业务常见格式:
1. YYYYMM 6 位(最常见)
2. YYYY-MM 6 位带分隔
3. YYYYMMDD 8 位(带日)
- 年份范围:1949 ~ 当前年 + 1
- 月份 ∈ [01,12]
- 应 ≥ 设立日期 IND-014-a —— 跨字段校验不在本 indicator 内(提示用户业务上核对)
样例:
✓ 199508 / 1995-08 / 19950808 / 202003
✗ 199513 / 189908 / 203008
"""
from __future__ import annotations
import re
from datetime import datetime
from .base import BaseStandard, ValidationResult
class PaymentStartMonthIndicator(BaseStandard):
standard_id = "IND-014-b"
standard_name = "GB/T 7408 启缴年月 格式"
applies_to_fields = [
"payment_start_month", "pay_start_month", "start_pay_month",
"qjyf", "qjyrm", "kcny",
]
comment_keywords = [
"启缴年月", "启缴月份", "缴费起始月", "缴存起始月",
"开始缴费年月", "起缴年月", "参保年月",
"单位启缴年月", "单位启缴月份",
]
description = (
"YYYYMM 6 位(最常见)/ YYYY-MM / YYYYMMDD;"
"月份 ∈ [01,12];年份 ∈ [1949, 当前年+1];"
"**业务上**应 ≥ 单位设立日期 IND-014-a —— 跨字段校验不在本 indicator 内"
)
group = "通用"
severity = "warning"
_REGEX_6 = re.compile(r"^(\d{4})(\d{2})$")
_REGEX_6_S = re.compile(r"^(\d{4})[\-./](\d{2})$")
_REGEX_8 = re.compile(r"^(\d{4})(\d{2})(\d{2})$")
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
year = month = day = None
if self._REGEX_6.match(v):
m = self._REGEX_6.match(v)
year, month = int(m.group(1)), int(m.group(2))
elif self._REGEX_6_S.match(v):
m = self._REGEX_6_S.match(v)
year, month = int(m.group(1)), int(m.group(2))
elif self._REGEX_8.match(v):
m = self._REGEX_8.match(v)
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
else:
return ValidationResult(
False, "", value,
"不符合 6 位 YYYYMM / YYYY-MM / 8 位 YYYYMMDD",
self.standard_id,
)
if not (1 <= month <= 12):
return ValidationResult(False, "", value, f"月份 {month} ∉ [01,12]", self.standard_id)
cur_year = datetime.now().year
if not (1949 <= year <= cur_year + 1):
return ValidationResult(
False, "", value,
f"年份 {year} ∉ [1949, {cur_year + 1}]",
self.standard_id,
)
if day is not None:
try:
datetime(year, month, day)
except ValueError:
return ValidationResult(
False, "", value,
f"日期 {year}-{month:02d}-{day:02d} 不合法(含闰年 2-29 校验)",
self.standard_id,
)
return ValidationResult(True, "", value, "", self.standard_id)
\ No newline at end of file
"""IND-015-a 发薪日 值域
参考:
- 业内惯例 + 劳动部门口径:
- 发薪日应为 1~31 之间的整数(每月对应日期)
- 0 / 32 及以上视为非法
- 部分系统在月底兜底为「99」表示月末(视同 28~31)—— 本 indicator 也接受
- 输入类型:数字字符串("1"~"31")/ "99" 月末;不接受浮点 / 负数 / 全角数字
样例:
✓ 1 / 5 / 10 / 15 / 20 / 28 / 30 / 31 / 99(月末)
✗ 0 / 32 / 1.5 / -1 / 三十
"""
from __future__ import annotations
from .base import BaseStandard, ValidationResult
class PayDayEnumIndicator(BaseStandard):
standard_id = "IND-015-a"
standard_name = "发薪日 值域"
applies_to_fields = [
"pay_day", "salary_day", "payment_day", "wage_day",
"fxr", "gfr", "gzffr",
]
comment_keywords = [
"发薪日", "工资发放日", "发放日", "工资日", "薪资发放日",
"发工资日期", "发工资日", "发放日期",
]
description = (
"数字 1~31(每月对应日期);99 表示月末(视同 28~31);"
"不接受 0 / 32+ / 浮点 / 负数 / 全角数字"
)
group = "通用"
severity = "warning"
_END_OF_MONTH = "99" # 部分业务系统的"月末"约定值
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
# 月末 99 单独允许
if v == self._END_OF_MONTH:
return ValidationResult(True, "", value, "", self.standard_id)
# 必须为纯 ASCII 数字(拒全角 "1" 等)
if not v.isascii() or not v.isdigit():
return ValidationResult(
False, "", value,
"发薪日必须为数字(不接受全角 / 中文 / 浮点)",
self.standard_id,
)
# 必须为 1~2 位整数(不是浮点 .5)
try:
n = int(v)
except ValueError:
return ValidationResult(False, "", value, "无法解析为整数", self.standard_id)
if n == 0:
return ValidationResult(False, "", value, "发薪日不能为 0", self.standard_id)
if n < 1 or n > 31:
return ValidationResult(
False, "", value,
f"发薪日 {n} ∉ [1, 31](每月最多 31 天)",
self.standard_id,
)
return ValidationResult(True, "", value, "", self.standard_id)
\ No newline at end of file
"""IND-016-a 开户银行行号 格式(CNAPS 12 位联行号)
参考:
- 中国人民银行《支付系统行号管理办法》(银办发〔2015〕222 号)
- CNAPS(China National Advanced Payment System)12 位联行号结构:
· 第 1-3 位:清算行代码(行别)
· 第 4-7 位:地区代码(4 位)
· 第 8-12 位:分支机构序号(5 位)
- 央行无公开校验位算法;本 indicator 仅做格式 + 主流行别白名单校验
主流行别字典(前 3 位)—— 不在字典内提示 warning,不直接拒绝:
100 邮政储蓄 / 102 工商 / 103 农业 / 104 中国 / 105 建设
201 国开 / 202 进出口 / 203 农发展
301 交通 / 302 中信 / 303 光大 / 304 华夏 / 305 民生 / 306 广发
307 平安 / 308 招商 / 309 兴业 / 310 浦发
313-318 城商行系列 / 402-416 部分股份制
501-510 农信社系列 / 601-610 农商行系列
样例:
✓ 102100099996(工商银行)/ 103100000026(农业)/ 301100000013(交通)
✗ 12345(5 位)/ A02100099996(带字母)/ 999100000001(前 3 位超主流字典 → warning)
"""
from __future__ import annotations
import re
from .base import BaseStandard, ValidationResult
# 主流 CNAPS 清算行代码(行别),前 3 位
_KNOWN_BANK_CODES = {
# 国有大行
"100", # 邮政储蓄银行
"102", # 工商银行
"103", # 农业银行
"104", # 中国银行
"105", # 建设银行
# 政策性银行
"201", # 国家开发银行
"202", # 进出口银行
"203", # 农业发展银行
# 股份制商业银行
"301", # 交通银行
"302", # 中信银行
"303", # 光大银行
"304", # 华夏银行
"305", # 民生银行
"306", # 广发银行
"307", # 平安银行
"308", # 招商银行
"309", # 兴业银行
"310", # 浦发银行
"313", # 渤海银行
"314", # 微众银行
"315", # 平安银行(补充)
"316", # 网商银行
# 城市商业银行系列(抽样,部分典型)
"402", # 北京银行
"403", # 上海银行
"404", # 江苏银行
"405", # 南京银行
"406", # 宁波银行
# 农信 / 农商系列
"501", # 农信社(央行)
"502", # 农村商业银行
"504", # 农信社(部分省份)
"601", # 农商行(部分省份)
}
class CnapsFormatIndicator(BaseStandard):
standard_id = "IND-016-a"
standard_name = "CNAPS 联行号 格式"
applies_to_fields = [
"cnaps", "bank_code", "bank_id", "payee_bank_code", "payee_bank_id",
"opening_bank_code", "union_pay_code", "lhh", "lhdm", "jjh",
]
comment_keywords = [
"联行号", "开户银行行号", "支付行号", "银行行号", "CNAPS",
"开户行行号", "清算行号", "支付系统行号",
"人民银行行号", "交换号",
]
description = (
"CNAPS 12 位联行号(数字);"
"格式:3 位清算行代码 + 4 位地区码 + 5 位网点序号;"
"前 3 位建议在主流行别字典内(不在字典内提示 warning,非硬拒)"
)
group = "通用"
severity = "warning"
_REGEX = re.compile(r"^\d{12}$")
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
v = str(value).strip()
if not v.isdigit():
return ValidationResult(
False, "", value,
f"联行号 {v!r} 含非数字字符",
self.standard_id,
)
if not self._REGEX.match(v):
return ValidationResult(
False, "", value,
f"联行号长度 {len(v)} ≠ 12",
self.standard_id,
)
bank3 = v[:3]
if bank3 not in _KNOWN_BANK_CODES:
# 主流字典外不直接拒(央行持续更新),仅 warning 提示
return ValidationResult(
False, "", value,
f"联行号前 3 位 {bank3!r} 不在主流行别字典(央行持续更新,请人工核对)",
self.standard_id,
)
return ValidationResult(True, "", value, "", self.standard_id)
\ No newline at end of file
"""IND-016-b 银行账号 格式
参考:
- 国内借记卡 / 对公账户常用 16~19 位数字(部分老账户 12~17 位 / 历史最长 22 位)
- JR/T 0025-2004《银行卡联网联合技术规范》:国内信用卡统一 Luhn 校验
- 国内借记卡 / 对公账户 Luhn 非 100% 强制(部分银行内部账户不校验)
校验规则:
1) 纯数字(清洗:去空格 / 横线)
2) 长度 ∈ [12, 22](覆盖主流银行)
3) 16~19 位时跑 Luhn(ISO/IEC 7812 / GB/T 14504);不通过仅 warning(不强拒)
Luhn 算法:
从右往左第 2 位起 ×2;若 ≥10 则两位相加;全部相加 mod 10 == 0
样例:
✓ 6222021234567890123(19 位工行)/ 6228480402564890018(19 位农行)/ 6217001234567890123
✗ 12345(5 位太短)/ abc1234567890(含字母)/ 6222021234567890124(Luhn 错 → warning)
"""
from __future__ import annotations
from .base import BaseStandard, ValidationResult
_MIN_LEN = 12
_MAX_LEN = 22
_LUHN_APPLY_MIN = 16
_LUHN_APPLY_MAX = 19
def _luhn_check(digits: str) -> bool:
"""ISO/IEC 7812-1 Luhn 算法:从右往左第 2 位起 ×2 求和 mod 10。
标准 Luhn 实现:右侧第 1 位是校验位,跳过;左 1 位 ×2,左 2 位 ×1...
"""
if not digits.isdigit() or len(digits) < 2:
return False
total = 0
# 反向遍历,第 1 位(末尾)是校验位不参与计算;第 2 位开始 ×2
for i, c in enumerate(reversed(digits[:-1])):
n = int(c)
if i % 2 == 0: # ×2 位
n *= 2
if n > 9:
n -= 9
total += n
# 加上校验位
total += int(digits[-1])
return total % 10 == 0
class BankAccountFormatIndicator(BaseStandard):
standard_id = "IND-016-b"
standard_name = "银行账号 格式"
applies_to_fields = [
"bank_account", "bank_account_no", "account_no", "acct_no",
"bankcard_no", "card_no", "yhh", "yhzh", "zh", "zhdm",
]
comment_keywords = [
"银行账号", "银行账户", "账号", "账户号", "卡号",
"对公账户", "对私账户", "银行卡号", "账户号码",
"银行账号(单位)", "单位银行账号",
]
description = (
"国内银行账号 12~22 位数字(清洗空格 / 横线);"
"16~19 位建议跑 Luhn(ISO/IEC 7812),不通过仅 warning(部分老账户不强制);"
"对公账户长度常见 19 位 / 借记卡 16-19 位 / 信用卡 16 位"
)
group = "通用"
severity = "warning"
def validate(self, value: str) -> ValidationResult:
if value is None or str(value) == "":
return ValidationResult(True, "", value, "空值跳过", self.standard_id)
# 清洗:去空格、横线
v = str(value).strip().replace(" ", "").replace("-", "")
if not v.isdigit():
return ValidationResult(
False, "", value,
f"银行账号 {value!r} 含非数字字符(含空格/横线已清洗)",
self.standard_id,
)
if not (_MIN_LEN <= len(v) <= _MAX_LEN):
return ValidationResult(
False, "", value,
f"银行账号长度 {len(v)} ∉ [{_MIN_LEN}, {_MAX_LEN}]",
self.standard_id,
)
if _LUHN_APPLY_MIN <= len(v) <= _LUHN_APPLY_MAX and not _luhn_check(v):
return ValidationResult(
False, "", value,
f"银行账号 Luhn 校验失败({len(v)} 位,按 ISO/IEC 7812)",
self.standard_id,
)
return ValidationResult(True, "", value, "", self.standard_id)
\ No newline at end of file
......@@ -21,6 +21,8 @@ class IdTypeEnumIndicator(BaseStandard):
standard_id = "IND-101"
standard_name = "证件类型枚举"
applies_to_fields = ["id_type", "cert_type", "document_type", "zjlb"]
comment_keywords = ["证件类型", "证件种类", "法人证件类型", "经办人证件类型",
"法人证件种类", "经办人证件种类"]
description = "代码必须在 {01 居民身份证 / 02 军官证 / 03 护照 / 04 永居证 / 05 港澳台居住证} 内"
group = "证件信息"
severity = "warning"
......
......@@ -18,6 +18,8 @@ class IdNumberIdCard18Indicator(BaseStandard):
standard_id = "IND-201"
standard_name = "证件号码-居民身份证 18 位(GB 11643-1999)"
applies_to_fields = ["id_no", "cert_no", "zjhm", "document_no"]
comment_keywords = ["法人证件号", "法人证件号码", "经办人证件号", "经办人证件号码",
"证件号码", "身份证号码", "身份证号"]
description = (
"18 位身份证:长度 + 正则 + 校验位 + 出生日期 全部通过。"
" 适用字段含 id_no / cert_no / zjhm / document_no(与 id_card 等并列,"
......
......@@ -28,6 +28,7 @@ class IdNumberPassportIndicator(BaseStandard):
standard_id = "IND-202"
standard_name = "证件号码-中国护照"
applies_to_fields = ["passport_no", "hz_no", "huzhao_no"]
comment_keywords = ["护照号", "护照号码", "法人护照号", "经办人护照号"]
description = (
"9 字符:G+8 / E+8 / E+1 字母(排除I/O)+7 / PE+7 / SE+7 / DE+7。"
" 不强校验位算法(公开材料未查到)。"
......
......@@ -25,6 +25,8 @@ class IdNumberHKMacaoResidenceIndicator(BaseStandard):
"hk_residence_no", "mo_residence_no", "tw_residence_no",
"gat_residence_no", "gatjzz_no",
]
comment_keywords = ["港澳台居住证", "港澳居住证", "台胞证",
"法人港澳台居住证", "经办人港澳台居住证"]
description = (
"18 位 + 地址码 ∈ {810000 香港, 820000 澳门, 830000 台湾} + GB 11643 校验位"
)
......
......@@ -18,6 +18,8 @@ class IdNumberForeignResidenceIndicator(BaseStandard):
standard_id = "IND-204"
standard_name = "证件号码-外国人永久居留身份证"
applies_to_fields = ["foreign_residence_no", "yd_no", "ywjzj_no", "prc_residence_no"]
comment_keywords = ["永居证", "永久居留证", "外国人永久居留身份证",
"法人永居证", "经办人永居证"]
description = "旧版 15 位(3 字母 ISO 3166-1 alpha-3 + 12 数字)/ 新版 18 位(首位 9)"
group = "证件信息"
severity = "warning"
......
......@@ -46,6 +46,8 @@ class NameCharsetIndicator(BaseStandard):
"name", "real_name", "customer_name", "user_name", "xm",
"user_real_name", "person_name",
]
comment_keywords = ["姓名", "客户姓名", "用户姓名", "真实姓名",
"法人姓名", "法人代表姓名", "经办人姓名", "联系人姓名"]
description = (
"允许:汉字 / 维吾尔等少数民族的「·」/ 全角点「.」/ 半角空格 / ASCII 字母。"
" 禁止:纯数字 / 纯特殊符号 / 控制字符 / Emoji"
......
......@@ -16,6 +16,8 @@ class NameLengthIndicator(BaseStandard):
"name", "real_name", "customer_name", "user_name", "xm",
"user_real_name", "person_name",
]
comment_keywords = ["姓名", "客户姓名", "用户姓名", "真实姓名",
"法人姓名", "法人代表姓名", "经办人姓名", "联系人姓名"]
description = "字符数 ∈ [2, 20]"
group = "身份信息"
severity = "warning"
......
"""IND-901 单位名称 全局唯一性(跨表聚合校验器 —— GROUP BY HAVING)
按单位名称维度跨表去重,疑似重复:
- 同一单位名出现在同一表多次 → 报「同表重复」
- 同一单位名跨多张表出现 → 报「疑似同名单位」(不一定重复,可能是同集团/子公司)
走 SQL `GROUP BY HAVING COUNT(*) > N`(同表层),SQL 模板:
web/sql/standards/sample_field_groups_single.sql
indicator 类本身不参与单字段自动发现,由 step7 第二轮显式调用。
"""
from __future__ import annotations
from .base import BaseStandard, ValidationResult
class CompanyNameGlobalUniquenessIndicator(BaseStandard):
standard_id = "IND-901"
standard_name = "单位名称 全局唯一性(跨表去重)"
applies_to_fields = []
description = (
"GROUP BY 单位名称 HAVING COUNT > N 检出同表重复。"
"疑似同名单位:跨多张表同单位名(不一定重复,可能是同集团/子公司)。"
"SQL 模板:web/sql/standards/sample_field_groups_single.sql"
)
group = "单位 / 法人 / 经办人"
severity = "warning"
depends_on = ["IND-012-a"]
def validate(self, value: str) -> ValidationResult:
# 单值模式不适用
return ValidationResult(
True, "", value,
"IND-901 仅支持跨表聚合(GROUP BY HAVING)",
self.standard_id,
)
\ No newline at end of file
"""IND-902 账户名 一致性(跨字段校验器 —— 同行两字段相等)
业务场景:
- 对公账户的"账户名"应等于"单位名称"
- 个人账户的"账户名"应等于"开户人姓名"
- 提取业务的"收款账户名"应等于"提取人姓名"
通过覆写 validate_rows() 实现:rows 元素含 {value_a, value_b} 两个键。
SQL 模板走 web/sql/standards/sample_field_pairs_two_text.sql,
由 step7 在「跨字段」轮调用。
"""
from __future__ import annotations
import re
from .base import BaseStandard, ValidationResult
def _normalize(name: str) -> str:
"""容错归一化:去空白 / 全角空格 / 不可见字符,统一小写比对。"""
if not name:
return ""
s = re.sub(r"\s+", "", str(name)) # 去所有空白(含全角空格  )
s = s.replace(" ", "")
s = s.lower()
return s
class AccountNameConsistencyIndicator(BaseStandard):
standard_id = "IND-902"
standard_name = "账户名 一致性(跨字段)"
applies_to_fields = []
description = (
"value_a(账户名 / 收款账户名)应等于 value_b(单位名称 / 开户人姓名 / 收款人姓名);"
"去空白差异(容错空格 / 全角空格)。"
"SQL 模板:web/sql/standards/sample_field_pairs_two_text.sql"
)
group = "单位 / 法人 / 经办人"
severity = "warning"
def validate(self, value: str) -> ValidationResult:
# 单值模式不适用
return ValidationResult(
True, "", value,
"IND-902 仅支持跨字段校验(validate_rows)",
self.standard_id,
)
def validate_rows(self, rows: list[dict]) -> list[dict]:
out = []
for row in rows:
a = (row.get("value_a") or "").strip()
b = (row.get("value_b") or "").strip()
if not a or not b:
continue
if _normalize(a) != _normalize(b):
# 容错前明示原值差异
out.append({
"value": f"{a} != {b}",
"value_a": a,
"value_b": b,
"reason": f"账户名 {a!r} 与参考字段 {b!r} 不一致",
"rule": "name_mismatch",
})
return out
\ No newline at end of file
......@@ -16,7 +16,7 @@
{
"key": "standards",
"title": "国标字段规范",
"description": "IND-001 ~ IND-005 系列:按 GB / GA 标准做字段值级别合规校验(格式 / 校验位 / 出生日期 / 15 位兼容 / 号段 / 编码存在性 / 固定电话)",
"description": "IND-001 ~ IND-005 系列:按 GB / GA 标准做字段值级别合规校验(格式 / 校验位 / 出生日期 / 15 位兼容 / 号段 / 编码存在性 / 固定电话 / 老代码兼容)",
"default_expand": true,
"children": [
{ "step_id": "std_ind_001_a" },
......@@ -25,6 +25,7 @@
{ "step_id": "std_ind_001_d" },
{ "step_id": "std_ind_002_a" },
{ "step_id": "std_ind_002_b" },
{ "step_id": "std_ind_002_c" },
{ "step_id": "std_ind_003_a" },
{ "step_id": "std_ind_003_b" },
{ "step_id": "std_ind_004_a" },
......@@ -84,6 +85,33 @@
{ "step_id": "std_ind_010_a" },
{ "step_id": "std_ind_011_a" }
]
},
{
"key": "business_unit",
"title": "单位 / 法人 / 经办人 字段规范",
"description": "IND-012 ~ IND-015 系列:单位基本信息(名称 / 类型 / 经济类型 / 行业代码 / 设立日期 / 启缴年月 / 发薪日);字段匹配走字段名 + 注释",
"default_expand": true,
"children": [
{ "step_id": "std_ind_012_a" },
{ "step_id": "std_ind_013_a" },
{ "step_id": "std_ind_013_b" },
{ "step_id": "std_ind_013_c" },
{ "step_id": "std_ind_014_a" },
{ "step_id": "std_ind_014_b" },
{ "step_id": "std_ind_015_a" }
]
},
{
"key": "business_unit_bank",
"title": "单位对公账户 字段规范",
"description": "IND-016 系列 + IND-901/902:开户银行行号 / 银行账号 格式 + 单位名跨表唯一性 + 账户名一致性(跨字段)",
"default_expand": true,
"children": [
{ "step_id": "std_ind_016_a" },
{ "step_id": "std_ind_016_b" },
{ "step_id": "std_ind_901" },
{ "step_id": "std_ind_902" }
]
}
]
}
......@@ -298,6 +298,26 @@ def _derive_target(meta: dict) -> str:
return "字段名或注释含「本地户籍 / 本市户籍 / 是否本地」"
if sid in ("IND-011-a",):
return "字段名或注释含「职工状态 / 账户状态 / 缴存状态」"
# 单位 / 法人 / 经办人 业务字段
if sid in ("IND-012-a",):
return "字段名或注释含「单位名称 / 公司名称 / 企业名称 / 机构名称」"
if sid in ("IND-013-a",):
return "字段名或注释含「单位类型 / 企业类型 / 单位性质 / 机构类型」"
if sid in ("IND-013-b",):
return "字段名或注释含「经济类型 / 经济性质 / 登记注册类型 / 企业注册类型」"
if sid in ("IND-013-c",):
return "字段名或注释含「行业代码 / 行业类别 / 行业分类 / 国民经济行业 / 所属行业」"
if sid in ("IND-014-a",):
return "字段名或注释含「设立日期 / 成立日期 / 注册日期 / 登记日期 / 开业日期」"
if sid in ("IND-014-b",):
return "字段名或注释含「启缴年月 / 启缴月份 / 缴费起始月 / 起缴年月 / 参保年月」"
if sid in ("IND-015-a",):
return "字段名或注释含「发薪日 / 工资发放日 / 发放日 / 工资日 / 薪资发放日」"
# 单位 / 法人 / 经办人 跨表 / 跨字段 indicator
if sid in ("IND-901",):
return "字段名或注释含「单位名称 / 公司名称 / 企业名称 / 机构名称」(跨表 GROUP BY 同名检测)"
if sid in ("IND-902",):
return "字段名含「账户名 / acct_name / yhmc」(同行配对参考字段:单位名称 / 法人姓名 / 经办人姓名)"
# 兜底:优先显示 comment 命中关键字,否则按字段名
if cmts and not fields:
return "**字段注释**含 " + " / ".join(cmts)
......@@ -378,6 +398,94 @@ def _derive_check(meta: dict) -> str:
"IND-601": "见 IND-004-a / IND-004-b;本 indicator 主要是按业务字段名(region / xzqh)路由到 IND-004 系列。",
"IND-602": "GB/T 3304-1991:01-56 + 补充码 97/98(81 穿青人非标准正文);接受字符串或数字存储形式。",
"IND-603": "GB/T 2261.2-2003:10=未婚 / 20=已婚 / 21=初婚 / 22=再婚 / 23=复婚 / 30=丧偶 / 40=离婚 / 90=未说明;非 GB/T 4761(家庭关系)。",
# ===== 单位 / 法人 / 经办人 业务字段规范 =====
"IND-012-a": (
"1) trim 后长度 ∈ [4, 100]\n"
"2) 不可全空白 / 全符号 / 全数字 / 占位符(无 / 暂无 / 未知 / TBD)\n"
"3) 不做语义/字号核名校验(业务系统兜底)\n"
"**业务上**:全局唯一性(IND-901 跨表聚合,不在本 indicator 内)"
),
"IND-013-a": (
"GB/T 12402-2017 单位类别分类与代码:\n"
" · 接受 2 位大类码(10 内资 / 20 港澳台 / 30 外资 / 99 其他)\n"
" · 接受 4 位中类码(公司 / 合伙 / 个体 / 其它细类)\n"
" · 接受文字形式(公司 / 有限责任公司 / 国有企业 / 股份合作 等)\n"
"**业务上**:推荐 4 位 GB 码优先,文字形式仅兜底"
),
"IND-013-b": (
"国统字〔2011〕86 号 经济类型分类与代码:\n"
" · 接受 3 位数字码(100/110/120/…/390;含子项 + 兜底 190/290/390)\n"
" · 接受文字形式(国有 / 集体 / 股份合作 / 联营 / 有限责任 / 股份 / 私营 / 中外合资 等)\n"
"**业务上**:推荐 3 位数字码优先"
),
"IND-013-c": (
"GB/T 4754-2017 国民经济行业分类:\n"
" · 大类 3 位(门类字母 + 2 位数字,如 A01 农业)\n"
" · 中类 4 位(门类字母 + 3 位数字,如 A011 谷物种植)\n"
" · 小类 5 位(门类字母 + 4 位数字,如 A0111 稻谷种植)\n"
" · 门类 ∈ A~T(20 个);数字段不以 00 开头\n"
"本 indicator **仅校验格式**,行业存在性留 IND-013-d(~2000 条表,加载不划算)"
),
"IND-014-a": (
"GB/T 7408-2005 日期表示法:\n"
" · 8 位 YYYYMMDD 或 8 位带分隔 YYYY-MM-DD / YYYY/MM/DD / YYYY.MM.DD\n"
" · 也接受 6 位 YYYYMM(仅月精度)\n"
" · 年份 ∈ [1949, 当前年];月份 ∈ [01,12];日期合法(含闰年 2-29)"
),
"IND-014-b": (
"GB/T 7408-2005 日期表示法(启缴年月):\n"
" · 6 位 YYYYMM(最常见)或 6 位带分隔 YYYY-MM\n"
" · 也接受 8 位 YYYYMMDD(带日)\n"
" · 年份 ∈ [1949, 当前年+1];月份 ∈ [01,12];带日时须合法\n"
"**业务上**:应 ≥ 单位设立日期 IND-014-a(跨字段校验不在本 indicator 内)"
),
"IND-015-a": (
"发薪日值域:\n"
" · 数字 1~31(每月对应日期)\n"
" · 99 = 月末(视同 28~31;部分业务系统约定)\n"
" · 不接受 0 / 32+ / 浮点 / 负数 / 全角数字(1 等)"
),
"IND-002-c": (
"GB 32100-2015 附录 A.1 老代码 → 新 USCC 兼容转换:\n"
" · 老代码 9 位(35 字符集,含 I/O/Z/S/V)\n"
" · 校验位:GB/T 12403-1990(MOD 11 + 映射表 [1,0,X,9,8,7,6,5,4,3,2])\n"
" · 转换映射:老代码首位 → 新 USCC 前 2 位(部门 + 类别):\n"
" 1 机关→11 / 2 事业→12 / 3 企业→91 / 4 社团→51 / 5/9 其他→99\n"
" · 行政区划默认填 110000(北京,无法追溯登记地时的兜底)\n"
" · 新 USCC 第 9-17 位 = 老代码第 2-9 位 + '0'\n"
" · 新 USCC 第 18 位 = 按 IND-002-b MOD 31-3 重新计算\n"
"**注意**:完整新 USCC 需持原证到登记机关换发;本 indicator 仅校验格式 + 推算骨架"
),
"IND-016-a": (
"CNAPS 12 位联行号 格式(人民银行清算系统):\n"
" · 结构:3 位行别代码 + 4 位地区代码 + 5 位网点序号(3+4+5=12 位)\n"
" · 必须纯数字\n"
" · 前 3 位在已知主流行别字典内(央行/国有/政策性/股份制/部分城商/农信)\n"
" · 央行未公开校验位权重表,本 indicator **不做位校验**,只做格式 + 行别字典校验"
),
"IND-016-b": (
"国内银行账号 格式:\n"
" · 长度 ∈ [12, 22](清洗空格 / 横线)\n"
" · 纯数字\n"
" · 16~19 位建议跑 Luhn(ISO/IEC 7812 / JR/T 0025-2004):\n"
" 从右往左第 2 位起 ×2;≥10 拆位加;总和 mod 10 == 0\n"
" · Luhn 不通过仅 warning(国内部分借记卡 / 对公账户不强制)"
),
"IND-901": (
"单位名称 全局唯一性(跨表 GROUP BY):\n"
" · 按字段名 / 注释匹配单位名称字段(company_name / 单位名称 等)\n"
" · SQL: GROUP BY 单位名称 HAVING COUNT(*) >= 2\n"
" · 检出同表同名重复(疑似同表重录入)\n"
"**注意**:跨多张表同名可能是同集团/子公司,不一定重复,需人工核对"
),
"IND-902": (
"账户名 一致性(跨字段):\n"
" · 同表同行配对 (账户名, 参考字段),参考字段包括:\n"
" 单位名称 / 法人姓名 / 经办人姓名 / 收款人姓名\n"
" · 容错归一化(去全/半角空格 + 大小写)后比较\n"
" · 不一致行作为 violation\n"
"**适用场景**:对公账户的账户名 = 单位名称;个人账户的账户名 = 开户人姓名"
),
}
if sid in special_checks:
return special_checks[sid]
......
This diff is collapsed.
-- ============================================================================
-- 单字段跨表去重 —— GROUP BY single_col HAVING COUNT(*) > N
-- · IND-901 单位名称全局唯一性(GROUP BY 公司名)
-- · IND-902 字段值分布(账户名一致性等场景复用)
-- 调用方:web/core/step_impl/step7_standards.py
-- 参数:
-- ${table} 表名(自动加引号)
-- ${val_col} 待聚合字段名(自动加引号)
-- ${min_count} 阈值(默认 2),出现次数 >= 该值才纳入疑似重复
-- ============================================================================
SELECT `${val_col}` AS value, COUNT(*) AS dup_count
FROM `${table}`
WHERE `${val_col}` IS NOT NULL AND TRIM(`${val_col}`) <> ''
GROUP BY `${val_col}`
HAVING COUNT(*) >= ${min_count}
ORDER BY dup_count DESC
LIMIT 200
\ No newline at end of file
-- ============================================================================
-- 抽样「两个文本字段」同行配对(IND-902 跨字段一致性用)
-- 调用方:web/core/step_impl/step7_standards.py
-- 参数:
-- ${table} 表名(自动加引号)
-- ${col_a} 第 1 字段名(自动加引号)
-- ${col_b} 第 2 字段名(自动加引号)
-- ${limit} 抽样上限
-- ============================================================================
SELECT `${col_a}` AS value_a, `${col_b}` AS value_b
FROM `${table}`
WHERE `${col_a}` IS NOT NULL AND TRIM(`${col_a}`) <> ''
AND `${col_b}` IS NOT NULL AND TRIM(`${col_b}`) <> ''
LIMIT ${limit}
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment