蓝领招工难在实名和日结
现在再有客户来聊蓝领招工,需求会上我前四个问题一定是:发工资的钱从哪出、有没有劳务派遣资质、考勤拿什么留证、发票谁开给谁。四个问题问完,这单能不能接、按什么价接,基本就有数了。
这套问题是第一回做这种单子换来的。当时拿到需求文档,前半段很轻:发职位、报名、面试、到岗,功能列表跟白领招聘差不多,寻思两周就能搭出个能演示的版本。做到后半段才知道,工厂要按人头结账,劳务公司要按天给工人发钱,工人干完一天当晚就要拿到工资,系统里突然冒出来身份证、银行卡、人脸、考勤机、银行回单,还有一堆合同和发票——这些在需求会上一个字都没出现过。后来才明白,客户真正紧张的是“钱别发错人”,发职位、报名那点功能,反倒成了整个项目里最不值一提的部分。

先把谁给谁结钱定下来
白领招聘卖的是简历和职位匹配,蓝领招工卖的是人到岗。一个工地要二十个木工,明天早上六点到,带证;一个电子厂要五十个临时工,做两周,包吃住。这些需求用职位描述表达不清楚,得用字段:工种、证书、年龄范围、可到岗日期、班组、是否接受夜班。
报名的也经常不是一个人。一个领队带着十几个人来,领队负责对接、考勤、垫付生活费。系统如果只建“求职者”一个角色,领队的账就没地方挂:他带来的人算谁的、离职找谁、工资发给谁,全是糊涂账。用工企业、劳务公司、工人,是三方。平台通常站在劳务公司这一侧,做软件加信息撮合,自己不签用工合同、不碰资金——“给用工企业提供人”的是劳务公司,不是平台;也有平台自己经营派遣的,那就更要知道自己有没有劳务派遣资质,角色一变,资金路径跟着变。
资质这一块一行代码都不用写,但它决定系统能不能上线。没有劳务派遣经营许可证,平台不能自己派人;没有人力资源服务许可证,做职业中介也有限制。系统可以先把流程跑通,合同主体和资质要客户自己去确认,这句话要写进合同,不要写进代码注释里。
用工需求最好做成模板:项目、工种、人数、工期、单价、结算方式、证书要求、吃住安排。模板能复制,下一批人来了改几个字段就能发。班组要单独建,排班、考勤、结算、借支都挂在班组下面,领队换人时只换负责人,不重建数据——落点是 hr_crew(id, tenant_id, agency_id, project_id, leader_worker_id, status) 一张表,排班和考勤挂 crew_id,换负责人就是 UPDATE 一行,历史数据不动。
用工企业只看两件事:人到没到、钱结没结。给他们开的账号,权限收在项目和考勤,不要让他们看到工人的身份证和银行卡。明文解密收敛到两个服务(代发文件生成、开票)加独立权限和审计,其余所有展示走统一脱敏——姓名首一尾一、身份证前六后四、卡号后四,用工企业的角色在代码层拿不到解密接口的路由。
实名要过三关
实名要解决的问题有三个:来的人是不是本人、银行卡是不是他的、同一个人有没有在别的劳务公司重复登记。只传一张身份证照片,三个都解决不了。
身份证二要素能过掉大部分错号,但过不了冒用;人脸活体检测能确认操作的人是本人,但成本高,工人也未必配合。比较稳的组合是身份证信息加银行卡四要素,人脸只在高风险场景用:首次入职、更换收款卡、单日结算金额超过阈值。
银行卡四要素是姓名、身份证号、卡号、手机号,由银行侧校验。四要素过了,能确认卡登记在这张身份证名下,卡不是随手填的;确认不了操作的人就是本人——整套身份被冒用时四项全一致。各家通道对四要素是否核验预留手机号有差异,接哪家查哪家的接口文档。
CREATE TABLE hr_worker (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
agency_id BIGINT NOT NULL,
employment_type VARCHAR(16) NOT NULL, -- DISPATCH 派遣 / OUTSOURC 外包 / REFERRAL 中介介绍 / PARTTIME 非全日制
name_enc VARBINARY(256) NOT NULL,
id_no_hash CHAR(64) NOT NULL, -- HMAC-SHA256(kms_pepper, id_no)
id_no_enc VARBINARY(256) NOT NULL,
mobile_enc VARBINARY(128) NOT NULL,
bank_card_enc VARBINARY(256) NOT NULL,
consent_version VARCHAR(32) NOT NULL,
consent_at DATETIME NOT NULL,
retention_until DATETIME NULL, -- 到期停用与清理任务扫它
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- PENDING / ACTIVE / EXITED
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_id_hash (tenant_id, id_no_hash),
KEY idx_agency (tenant_id, agency_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
身份证号不能明文存,也不能只存哈希:结算、开票、银行代发都要用原值。做法是加密存原值、哈希存索引,查询用哈希,展示用脱敏; employment_type 决定谁发工资、谁缴社保、个税按工资薪金还是劳务报酬,计薪和开票都按它分支。密钥材料放 KMS 不入库,备份和导出文件不落本机磁盘。
同一个人在两家劳务公司登记,是常见情况。全局唯一会撞上数据隔离,按租户唯一又拦不住重复。折中的做法是用 HMAC 做跨租户比对,只返回“已存在”的布尔值,不返回对方公司名称;全局盐哈希挡不住身份证号的离线字典攻击,18 位号码的可枚举空间太小。这个接口要审计,谁查过、什么时候查的、目的,都要留痕,比对能力要写进和两家租户的服务协议。
人脸和身份证是敏感个人信息。采集前要有单独同意,不能捆在注册协议里;要告诉工人用途、保存期限、谁能看,告知文案里的期限和 retention_until 由同一份配置出,别各写各的;工人拒绝人脸时,要有替代核验方式。这几条是《个人信息保护法》的硬要求,没有权衡的空间,别把它当产品体验问题去取舍。
年龄要单独校验。十六岁以下不能用,任何岗位都不行;十六到十八岁属于未成年工,不能安排高危工种和超时加班,夜班要不要排,按当地规定确认。身份证号能算出年龄,但不要在前端算,规则会变,校验要放在服务端。
最后多说一句:证件是真的,人也未必是本人。四要素全过、后面才发现被冒名的单子我们碰见过,所以首次入职那一步人脸别省。
考勤是发钱的证据
工地和工厂的考勤,和写字楼不一样。网络差、光线差、工人不愿意装 App、班组长代打卡,都是常态——工地上最常见的画面,是班长收工后拿着十几台手机挨个补打卡。系统做得再漂亮,考勤数据不可信,后面的结算全是争议。还有一条根基要先立住:日结说的是钱当天结清,不是说按天计价——灵活用工的计价单位是小时,工人几点上工、几点收工、一天干了几个小时,才是这套考勤的原语。
所以考勤的第一个对象不是打卡,是班次。工人今天上白班还是夜班、几点到几点、中间休多少分钟,都是排出来的;排班在先,打卡在后,考勤才有比对基准。班次做成模板:
CREATE TABLE hr_shift (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
name VARCHAR(32) NOT NULL, -- 白班 / 中班 / 夜班
start_time TIME NOT NULL, -- 08:00
end_time TIME NOT NULL, -- 20:00;跨天班 end_time < start_time
break_minutes INT NOT NULL DEFAULT 0, -- 班中休息,算工时时扣掉
UNIQUE KEY uk_name (tenant_id, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE hr_schedule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
crew_id BIGINT NOT NULL,
worker_id BIGINT NOT NULL,
site_id BIGINT NOT NULL,
work_date DATE NOT NULL,
shift_id BIGINT NOT NULL,
UNIQUE KEY uk_plan (tenant_id, worker_id, work_date, shift_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
一天可以排两个班,上午电子厂、下午仓库帮工,每个班次一行考勤,互不挤占。没排班的临时上工走默认班次并单独标记,事后补排班,别让打卡记录没有比对对象。
打卡要至少留三样东西:时间、地点、人。时间是服务器时间,不是手机时间;地点是定位加围栏,围栏半径按场地定;人是人脸或工牌,二者选一,不要都做成必填。离线打卡要允许,补传时带上设备时间和服务器接收时间,两个都存。
离线补传会出现重复打卡。同一个工人同一台手机传了两次,服务端要按工人、日期、班次去重(同一班重复打卡合并取首尾),不能靠客户端保证。补传的记录标记来源,结算时能看出来哪些是事后补的。
CREATE TABLE hr_attendance (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
crew_id BIGINT NOT NULL,
worker_id BIGINT NOT NULL,
site_id BIGINT NOT NULL,
work_date DATE NOT NULL,
shift_id BIGINT NOT NULL, -- 关联 hr_shift,考勤落在班次上
punch_in_at DATETIME NULL,
punch_out_at DATETIME NULL,
work_minutes INT NOT NULL DEFAULT 0, -- 扣休息后的净工时,单位分钟
overtime_minutes INT NOT NULL DEFAULT 0,-- 超出班次时长的部分,倍数看 pay_rule
late_minutes INT NOT NULL DEFAULT 0, -- 迟到分钟,宽限见 pay_rule
device_at DATETIME NULL, -- 设备时间,离线补传时带上
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 服务器接收时间
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL, -- 围栏校验和争议举证都靠坐标
verify_method VARCHAR(16) NOT NULL, -- FACE / BADGE
source VARCHAR(16) NOT NULL, -- APP / GATE 闸机 / OFFLINE 补传 / BACKFILL 补卡
status VARCHAR(16) NOT NULL DEFAULT 'NORMAL', -- NORMAL / CONFIRMED / ADJUSTED
confirmed_at DATETIME NULL,
confirm_channel VARCHAR(16) NULL, -- APP / SMS
evidence_url VARCHAR(500) NULL,
UNIQUE KEY uk_worker_day_shift (tenant_id, worker_id, work_date, shift_id),
KEY idx_site_date (tenant_id, site_id, work_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
工时按打卡对减去班中休息,按分钟记;超出班次时长的部分切出来算加班,迟到按班次开始时刻加宽限分钟判定。这些怎么折成钱,全是配置,不是写死在代码里的 1.5 倍。工作日 150%、休息日 200%(能补休就不发)、法定节假日 300% 是法律下限,具体倍数、加班按打卡时长还是按审批时长起算、满不满三小时算半天,每家客户都不一样;请假也不是一个“扣”字——事假不带薪、病假按比例、旷工倒扣,类型和口径都要配。落在计薪规则表里,留版本:
CREATE TABLE hr_pay_rule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
version VARCHAR(16) NOT NULL,
unit VARCHAR(16) NOT NULL, -- HOURLY 时薪 / DAILY 日薪 / PIECE 计件
hour_rate_cent INT NULL, -- 时薪(分);从月薪折算的写清口径:月工资 ÷ 21.75 ÷ 8
rounding VARCHAR(16) NOT NULL, -- 工时舍入:MINUTE / HALF_HOUR / HOUR
ot_weekday_pct INT NOT NULL DEFAULT 150,
ot_weekend_pct INT NOT NULL DEFAULT 200,
ot_holiday_pct INT NOT NULL DEFAULT 300,
late_grace_min INT NOT NULL DEFAULT 0, -- 迟到宽限,宽限内不算迟到
night_allowance_cent INT NOT NULL DEFAULT 0,
is_active TINYINT NOT NULL DEFAULT 1,
UNIQUE KEY uk_ver (tenant_id, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
配置保存时校验加班倍数下限——平日不低于 150、休息日 200、法定节假日 300,别把违法也做成可配置。舍入放到结算那一刻、按 rounding 配置来:打卡时按分钟存,半小时一刀看着简单,一个月下来能差出半天工资。
请假单独一张表,别混在考勤状态里:
CREATE TABLE hr_leave (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
worker_id BIGINT NOT NULL,
leave_type VARCHAR(16) NOT NULL, -- PERSONAL 事假 / SICK 病假 / ABSENT 旷工(系统判定)
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
paid TINYINT NOT NULL DEFAULT 0, -- 该类型是否带薪,跟类型配置走
approved_by VARCHAR(32) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- PENDING / APPROVED / REJECTED
UNIQUE KEY uk_time (tenant_id, worker_id, start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
批了的请假自动关联考勤:请假时段和班次重叠的部分,结算时按类型扣。请假表不替考勤表回答“实际干了多久”——考勤答实际,请假答批准了什么,两个数对得上,账才说得清。
考勤结果要让工人确认。不用每条记录都弹窗,收工时推一条汇总就够:今天在哪个工地、几个小时、按什么单价、预计多少钱。工人点确认,或者回一条短信,落点是 confirmed_at 和 confirm_channel 两列,status 走到 CONFIRMED。没有确认的考勤,月底就是一笔扯不清的账。
补卡要有审批,这个口子千万别开大。班组长可以补,但不能改已经确认的记录;要改一律走审计表(原值、新值、原因、操作人),原表不许 UPDATE。考勤是结算的输入,输入能被人悄悄改,月底对出来再对的账,工人一句“打卡记录是你们改的”就全废了。
计件是另一套考勤。服装厂按件算,班组长每天录入产量,工人确认;系统要区分合格品和返工件,返工不计件。只按打卡时长算钱,工人会磨洋工;只按件数算,质量没人管。两种模式可以并存,但要在项目上配清楚。
日结的难点在钱从哪来
日结不走月度摊算那套口径。它是每天生成一张结算单,当天或第二天打款。算钱那部分其实是简单的,麻烦在资金:钱先从谁那里出,再打到谁的卡上。常见有两种模式。一种是劳务公司把钱充进银行或持牌机构的存管(监管)户,平台按结算单向存管户发代付指令、记影子账,钱从专用户直接到工人卡;另一种是平台垫资,先发给工人,再和劳务公司结算。前者的余额记在存管户、不沉淀在平台对公户,平台库里只有指令流水和渠道侧余额快照,服务费走独立的应收账单,跟代发指令物理分开;垫一期是应收账款风险,常态化垫资就接近商业保理或放贷,没有资金能力不要碰。日结这件事,代码算错还能改,当天钱调不过来就真出事了。
银行代发也不是调一个接口就完事。要生成代发文件、加密、上传、等回单;回单里有成功、失败、退票,失败原因各不相同。系统要能逐笔核销,失败的挂起,不能自动重试到成功。卡号错了,重试一百次还是错。退票是钱回来了:payout 状态加 RETURNED,退回的钱落回存管户并记一条退款流水,重发在原结算单上二次打款,有 uk_settle 挡着不会出第二笔在途;或者出调整单冲下期,二选一写死。
回单失败的原因要分类型:卡号错、户名不符、账户冻结、银行维护,前三种要人工处理,最后一种可以等下一次批次。所以失败原因不能存渠道的自由文本了事:加一列内部枚举 fail_code 做程序化分流,fail_reason 只存渠道原文留证,混在一起重试只会把能发的钱也拖住。回单没回来之前,这笔款在系统里只能算在途,谁都不能手动改成已结。
CREATE TABLE hr_payout (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
batch_no VARCHAR(32) NOT NULL,
worker_id BIGINT NOT NULL,
settle_id BIGINT NOT NULL,
amount_cent INT NOT NULL,
channel VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'QUEUED', -- QUEUED / SENT / SUCCESS / FAILED / SUSPENDED / RETURNED
fail_code CHAR(16) NULL, -- 内部枚举分流:CARD_NO_MISMATCH / NAME_MISMATCH / FROZEN / BANK_MAINTAIN / OTHER
fail_reason VARCHAR(200) NULL,
receipt_no VARCHAR(64) NULL,
paid_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_settle (tenant_id, settle_id),
KEY idx_batch (tenant_id, batch_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE hr_settlement (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
worker_id BIGINT NOT NULL,
work_date DATE NOT NULL,
net_amount_cent INT NOT NULL,
pay_rule_version VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- PENDING / PAYING / PAID / ADJUSTED
UNIQUE KEY uk_worker_day (tenant_id, worker_id, work_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE hr_settlement_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
settle_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL, -- BASE / OVERTIME / ALLOWANCE / MEAL_DEDUCT / LOAN_DEDUCT / ADJUST
source_type VARCHAR(16) NOT NULL,
source_id BIGINT NOT NULL,
amount_cent INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE hr_loan (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
worker_id BIGINT NOT NULL,
amount_cent INT NOT NULL,
outstanding_cent INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
worker_signed_at DATETIME NULL, -- 扣款授权签署时间,没有它从工资里扣借支站不住
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
uk_settle (tenant_id, settle_id) 保证一张结算单只能有一笔在途打款,失败挂起后重发走同一条结算单,换批次也不换约束;生成打款以 UPDATE hr_settlement SET status='PAYING' WHERE id=? AND status='PENDING' 影响行数为 1 作为事务前提,插 payout 和这条 UPDATE 在同一个事务里。
结算单要冻结。工时、单价、扣款、借支,在生成结算单的那一刻快照下来,pay_rule_version 记当时用的规则;后面改考勤、改单价,不能影响已经生成的单。工人对金额有异议,走调整单,冲在下一次,不要直接改原单。借支是蓝领场景里绕不开的东西。工人预支五百,发薪日扣回;预支没还完就离职,要找担保人或从劳务公司应收里扣。借支要有独立流水(hr_loan 一张表),不能做成结算单上的一个负数备注;扣回时在结算单上挂一条 LOAN_DEDUCT 的明细,指向借款流水。
一个工人当天白班 8 小时、时薪 25,白班工时 200;下班后留下来搬了两小时货,按 pay_rule 的平日 150% 算加班 75;中午餐费扣 20,上周借支扣 100,当天实发 155。这五个数在 hr_settlement_item 里各占一条:白班工时来自打卡对班次,加班来自超班的分钟数乘配置倍数,餐费来自后勤,借支来自借款流水,实发才是结算单的结果。任何一个数没有来源,工人都会问。
每天的打款批次要和结算单对总数,差一分都要停下来查。日结的容错时间只有几个小时,白天没发现的错,晚上就会变成电话。钱真正落到工人卡上、回单逐笔核销完,这一天的账才关得上。
四流要能对上,发票流最容易断
用工企业付钱给劳务公司,劳务公司发给工人,平台收服务费。合同流、资金流、发票流、业务流,四条线要对得上,税务和审计查的就是这个。
| 流 | 谁到谁 | 系统里对应什么 |
|---|---|---|
| 合同流 | 用工企业与劳务公司签用工协议 | 项目合同、单价、结算周期 |
| 资金流 | 用工企业付款、劳务公司发薪 | 收款单、充值余额、代发批次 |
| 发票流 | 劳务公司开票给用工企业 | 开票申请、发票号、红冲记录 |
| 业务流 | 工人实际到岗、考勤、结算 | 排班、打卡、结算单 |
平台如果直接从用工企业收钱、再发给工人,很容易被认定为支付业务甚至资金池。比较稳的做法是资金走持牌机构或银行代发,平台只做指令和记录;服务费单独收,不和工资混在一个池子里。工地的例子要单独补一条:工程建设领域受《保障农民工工资支付条例》约束,工资必须走总包专用账户、由总包代发到农民工本人银行卡,实名制还要接入建筑工人实名制监管平台——项目识别为工程建设时,代发渠道固定为总包专户,平台做考勤和实名名单同步就够了,资金路径和存管户方案完全不同。发票也一样。工资、社保、服务费,税率和开票项目不同,混在一起开,客户财务不会收。系统要能把一张结算单拆成几个开票项,退货或差错了能红冲,红冲要关联原发票。
四流里最容易断的就是发票流,这个坑我自己踩过:业务做了、钱付了、合同也签了,发票项目开错一个,客户财务把整笔款挂起,等业务、财务、税务三方确认完才放行,月底对账才发现,这个月的现金流就断了。
服务费怎么收,也要在合同里写清楚:按人头每天收,还是按结算金额抽点;含不含招聘、保险、驻场管理。系统里的服务费账单要能追溯到项目和人头,不能月底拍一个数。
平台的位置,在合同和资金之间
这套系统里,平台最容易越界的两处,一是替客户签劳动合同,二是替客户保管工资。前者让平台变成用人单位,后者让平台变成支付机构。这两个身份,写代码扛不起来。
能做的是把实名做实、考勤做成证据、结算单冻结成快照,打款交给持牌渠道,回单逐笔核销。用工关系、个税、社保、发票交给劳务公司和财务,系统只记录、不兜底。上线前问客户一句:工人明天拿不到钱,第一个找的是谁。答案如果在平台,边界就还没设计完。
系列:上一篇 线上线下一套库存 | 下一篇 行业系统做成白牌,先看领域模型能不能复用 | 相关 部门、门店、代理商,一张 sys_dept 表撑不住这三层