feat: 北京解聘合规增强 — 政策法规库+地区过滤+工会回执证据链

1. 政策法规库(RAG知识库):
   - 新增6条北京地区单方解除劳动合同工作指引种子数据
   - 涵盖通知工会程序、函件内容要求、回执要求、监督提示函、仲裁审查等
   - 企业用户通过AI问答可检索到北京地区工会通知规定

2. 地区差异化合规检查:
   - getChecklistForReason 增加 orgCity 参数
   - 工会通知检查项仅北京地区显示(FAULT/NONFAULT/LAYOFF)
   - 前端解聘方式说明中北京工会提示仅北京地区动态显示
   - 非北京地区不显示工会通知检查项,避免误导

3. 工会回执上传+证据链留存:
   - 后端新增3个接口:上传回执文件、保存回执信息、获取回执信息
   - 回执信息保存到草稿 checklistOverrides
   - 自动追加到证据链(appendEvidence),作为劳动仲裁举证材料
   - 前端合规检查步骤增加工会回执上传区域
   - 确认提交步骤展示回执文件链接
   - 新增 uploads 静态文件服务

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-15 16:01:02 +08:00
parent e8c6d27979
commit 7e720d9bfc
6 changed files with 332 additions and 34 deletions
+12
View File
@@ -697,6 +697,18 @@ export const terminationApi = {
/** 批量预览 */
batchPreview: (items: Record<string, unknown>[]) =>
post('/termination/batch/preview', { items }),
/** 上传工会回执文件 */
uploadUnionReceipt: (draftId: string, file: File) => {
const formData = new FormData()
formData.append('file', file)
return post(`/termination/draft/${draftId}/union-receipt/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
},
/** 保存工会回执信息 */
saveUnionReceipt: (draftId: string, data: Record<string, unknown>) =>
post(`/termination/draft/${draftId}/union-receipt`, data).then(unwrap<any>()),
/** 获取工会回执信息 */
getUnionReceipt: (draftId: string) =>
get(`/termination/draft/${draftId}/union-receipt`).then(unwrap<any>()),
}
// ========== 制度相关 ==========
+133 -13
View File
@@ -8,7 +8,7 @@ import { Stepper } from '../components/ui/Stepper'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
import jsPDF from 'jspdf'
import { rosterApi, terminationApi, esignApi } from '../lib/api-services'
import { rosterApi, terminationApi, esignApi, settingsApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -42,10 +42,9 @@ const REASONS = [
steps: [
'第一步:收集并固化证据(违纪事实、制度依据、证人证言等)',
'第二步:确认规章制度经过民主程序制定并已向员工公示(签收记录)',
'第三步:提前5个工作日将解除理由书面通知工会(北京地区必经程序),工会提出意见后书面回复工会',
'第三步:将解除理由通知工会,工会提出意见后书面回复工会',
'第四步:向员工送达《解除劳动合同通知书》,注明解除依据和事实',
'第五步:办理工作交接、社保减员、档案转移',
'⚠ 北京地区:单方解除须提前5个工作日书面通知工会,未建立工会的通知上一级工会(经营地乡镇/街道/园区/开发区总工会)。可在「文本模板库」使用《拟解除劳动合同通知工会函》模板',
'⚠ 注意:证据不足或制度未公示可能导致违法解除,建议咨询律师',
],
},
@@ -59,11 +58,9 @@ const REASONS = [
'第一步(不胜任):进行绩效考核,确认不胜任事实',
'第二步(医疗期满):另行安排合适岗位,员工仍不能胜任',
'第二步(不胜任):进行培训或调岗,再次考核仍不胜任',
'第三步:提前5个工作日书面通知工会(北京地区必经程序),收到工会回执后再解除',
'第四步:提前30天书面通知员工,或额外支付1个月代通知金',
'第五步:支付经济补偿金(N',
'第六步:出具解除证明,办理交接、社保减员',
'⚠ 北京地区:单方解除须提前5个工作日书面通知工会,可在「文本模板库」使用《拟解除劳动合同通知工会函》模板',
'第三步:提前30天书面通知员工,或额外支付1个月代通知金',
'第四步:支付经济补偿金(N',
'第五步:出具解除证明,办理交接、社保减员',
],
},
{
@@ -74,13 +71,11 @@ const REASONS = [
steps: [
'第一步:确认符合法定裁员情形,准备相关证明材料',
'第二步:提前30天向工会或全体职工说明情况(书面会议记录)',
'第三步:听取工会或职工意见,形成意见处理方案,收到工会书面回执',
'第三步:听取工会或职工意见,形成意见处理方案',
'第四步:向当地劳动行政部门报告裁员方案',
'第五步:确定裁员名单(优先留用法定保护人员)',
'第六步:提前5个工作日书面通知工会拟解除名单(北京地区必经程序',
'第七步:向员工送达解除通知,支付经济补偿金(N)',
'第八步:办理交接、社保减员、档案转移',
'⚠ 北京地区:裁员属单方解除,须提前5个工作日书面通知工会,可在「文本模板库」使用《拟解除劳动合同通知工会函》模板',
'第六步:向员工送达解除通知,支付经济补偿金(N',
'第七步:办理交接、社保减员、档案转移',
],
},
{
@@ -176,6 +171,14 @@ export default function Termination() {
const [employeeId, setEmployeeId] = useState('')
const [terminationDate, setTerminationDate] = useState('')
// 获取组织所在城市,用于地区差异化合规提示(如北京通知工会程序)
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: () => settingsApi.org(),
staleTime: 300000,
})
const isBeijing = !orgData?.city || orgData?.city === '北京' || orgData?.city === '北京市' || (orgData?.city as string)?.includes('北京')
// 从 URL 参数预填员工和解聘类型(从花名册操作栏跳转)
useEffect(() => {
const urlEmployeeId = searchParams.get('employeeId')
@@ -197,6 +200,16 @@ export default function Termination() {
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
/** 工会回执上传(北京地区单方解除证据链) */
const [unionReceipt, setUnionReceipt] = useState<{
receiptNo: string
unionName: string
receiptDate: string
fileUrl: string
fileName: string
unionOpinion: string
} | null>(null)
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [editingCompField, setEditingCompField] = useState<string | null>(null)
const [editCompValue, setEditCompValue] = useState<number>(0)
const [editCompReason, setEditCompReason] = useState('')
@@ -1377,6 +1390,17 @@ ${items}
{r.steps.map((s, i) => (
<li key={i} className={`text-xs leading-relaxed ${s.includes('⚠') ? 'text-amber-600 font-medium' : 'text-gray-600'}`}>{s}</li>
))}
{/* 北京地区单方解除须通知工会 */}
{isBeijing && ['FAULT', 'NONFAULT', 'LAYOFF'].includes(r.value) && (
<>
<li className="text-xs leading-relaxed text-amber-600 font-medium">
5///使
</li>
<li className="text-xs leading-relaxed text-amber-600 font-medium">
</li>
</>
)}
</ol>
</div>
</div>
@@ -1534,6 +1558,101 @@ ${items}
</div>
)
})}
{/* 工会回执上传区域(北京地区单方解除,checklist 包含 union_receipt 项时显示) */}
{checklistItems?.some(item => item.key === 'union_receipt') && checklist['notify_union'] && (
<div className="border rounded-md p-3 space-y-3 bg-blue-50/30">
<div className="text-xs font-medium text-gray-700 flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-primary" />
</div>
<div className="text-xs text-gray-500">
稿
</div>
{unionReceipt && unionReceipt.fileUrl ? (
/* 已上传回执展示 */
<div className="space-y-2 bg-white rounded-md p-2.5 border">
<div className="flex items-center gap-2 text-xs">
<CheckCircle className="w-4 h-4 text-safe" />
<span className="font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div>{unionReceipt.receiptNo || '—'}</div>
<div>{unionReceipt.unionName || '—'}</div>
<div>{unionReceipt.receiptDate || '—'}</div>
<div><a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{unionReceipt.fileName}</a></div>
</div>
{unionReceipt.unionOpinion && (
<div className="text-xs text-gray-600">{unionReceipt.unionOpinion}</div>
)}
<Button size="sm" variant="secondary" onClick={() => setUnionReceipt(null)}></Button>
</div>
) : (
/* 上传表单 */
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label></Label>
<Input value={unionReceipt?.receiptNo || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptNo: e.target.value }))} placeholder="如:2026第001号" />
</div>
<div>
<Label></Label>
<Input value={unionReceipt?.unionName || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionName: e.target.value }))} placeholder="如:XX街道总工会" />
</div>
<div>
<Label></Label>
<Input type="date" value={unionReceipt?.receiptDate || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptDate: e.target.value }))} />
</div>
<div>
<Label></Label>
<Input value={unionReceipt?.unionOpinion || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionOpinion: e.target.value }))} placeholder="如有意见建议请填写" />
</div>
</div>
<div>
<Label></Label>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf,.bmp"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file || !draftId) return
setUploadingReceipt(true)
try {
const res = await terminationApi.uploadUnionReceipt(draftId, file)
setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), fileUrl: res.fileUrl, fileName: res.fileName }))
toast.success('文件上传成功')
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '上传失败')
} finally {
setUploadingReceipt(false)
}
}}
className="block w-full text-xs text-gray-500 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:bg-primary file:text-white hover:file:bg-primary/90"
/>
{uploadingReceipt && <div className="text-xs text-gray-400 mt-1">...</div>}
</div>
{draftId && unionReceipt?.fileUrl && (
<Button
size="sm"
onClick={async () => {
try {
await terminationApi.saveUnionReceipt(draftId, unionReceipt)
toast.success('工会回执已保存到证据链')
setChecklist({ ...checklist, union_receipt: true })
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '保存失败')
}
}}
></Button>
)}
{!draftId && (
<div className="text-xs text-amber-600">稿</div>
)}
</div>
)}
</div>
)}
</div>
)}
@@ -1896,6 +2015,7 @@ ${items}
<div className={checklist['notify_union'] ? 'text-safe' : 'text-danger'}>
{checklist['notify_union'] ? '已通知' : '⚠ 未通知工会(北京地区单方解除必经程序)'}
{checklist['notify_union'] && checklist['union_receipt'] ? ' · 已收到回执' : checklist['notify_union'] ? ' · 未收到回执' : ''}
{unionReceipt?.fileUrl && <a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="ml-1 text-primary hover:underline"></a>}
</div>
)}
</div>