Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
---
description: 个人办公申请必须走统一流程,禁止再做孤立页面和三五个文字链筛选
alwaysApply: true
---
# 办公申请怎么长
个人办公是「人发起、人跟踪、落地到台账」。不要再按用户点到哪一页就只改那一页。
## 列表筛选
- 用 `ApplyBucketBar`Segmented + `?bucket=`),有选中态。
- 标准桶:全部 / 待审批 / 已通过 / 已驳回。领域附加桶(外带未还、占用中、待入库)可以加,不能替代标准桶。
- 禁止再在页头放三个无选中态的 `Link`(「全部」「待审批」「外带未还」这种)。
## 菜单
- 「我的申请」是跟踪入口,人事/用章/办公申请是发起入口,待我审批是审批入口。不要做成三套互不相通的列表。
- 申请人页只看自己的单;台账(用章登记、资产管理、采购申请)在业务模块。
## 审批通过必须写下游
| 申请 | 落到 |
|---|---|
| 资产购置 | 采购单 + 待入库台账 + 付款待办 |
| 资产领用 / 用车 | 占用记录(挂台账 id),交回不改保管人 |
| 出差 | 差旅报销草稿 |
| 用章外带 | 归还待办 |
保管人是台账责任人,占用是谁正在用。占用时不要把保管人改成持有人。
+11
View File
@@ -0,0 +1,11 @@
---
description: Windows 客户端打包机与 SSH 端口
alwaysApply: true
---
# Windows 端打包
- 打包机:`10.1.1.202`SSH **端口 22022**(不是 22),用户 `administrator`。
- 产出必须是可安装的 **`.exe`**Inno Setup 安装包),不要只丢 zip。
- 安装包上传到生产:`https://oa.fysxkj.com/fengying-oa-setup.exe`,并写入 `apps/api/data/app-release.json` 的 `windowsUrl`。
- 安卓仍在本机 `/opt/apps/native` 打 APK。
+44
View File
@@ -0,0 +1,44 @@
NODE_ENV=development
APP_PORT=3000
APP_NAME=enterprise-ops
API_PREFIX=api/v1
CORS_ORIGINS=http://localhost:5173
DATABASE_URL=postgresql://ops:ops_dev_only@localhost:5432/enterprise_ops
REDIS_URL=redis://localhost:6379
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=opsadmin
MINIO_SECRET_KEY=opsadmin_dev_only
MINIO_BUCKET=enterprise-ops
JWT_ACCESS_SECRET=change-me-access-secret-dev-only
JWT_REFRESH_SECRET=change-me-refresh-secret-dev-only
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_REGION=cn-qingdao
ALIYUN_SMS_DEFAULT_SIGN=江苏风影随行科技
VIVO_PUSH_APP_ID=
VIVO_PUSH_APP_KEY=
VIVO_PUSH_APP_SECRET=
OPPO_PUSH_APP_ID=
OPPO_PUSH_APP_KEY=
OPPO_PUSH_APP_SECRET=
OPPO_PUSH_MASTER_SECRET=
MEIZU_PUSH_APP_ID=
MEIZU_PUSH_APP_KEY=
MEIZU_PUSH_APP_SECRET=
POSTGRES_USER=ops
POSTGRES_PASSWORD=ops_dev_only
POSTGRES_DB=enterprise_ops
POSTGRES_PORT=5432
REDIS_PORT=6379
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
+49
View File
@@ -0,0 +1,49 @@
node_modules
dist
build
coverage
.env
*.log
.DS_Store
apps/api/generated
apps/web/dist
.idea
.vscode
apps/api/uploads
import
apps/api/.aliyun-sms.env*
apps/api/.application-sms.yml*
# 本地 SDK / 工具链(不进仓库)
flutter/
android-sdk/
.pnpm-store/
backup/
node_modules/
# 安装包与归档
*.apk
*.exe
*.deb
*.dmg
*.msi
*.aab
*.ipa
*.tar.gz
*.zip
*.mp4
# Flutter 构建缓存
.dart_tool/
apps/native/build/
apps/native/dist/
apps/native/.flutter-plugins
apps/native/.flutter-plugins-dependencies
# Android 签名(勿上传)
**/key.properties
**/*.jks
**/*.keystore
# 临时打包
fengying-oa-code-*.tar.gz
+661
View File
@@ -0,0 +1,661 @@
# 风影智慧办公 · 全系统转交文档
更新日期:2026-09-01
当前手机包:**3.1.8+318**`com.fengying.oa.mobile`
本次生产修复与当前真实状态:**先读 `/opt/HANDOFF.md`,再读本文件第 0、2、3、14 节和 `.cursor/rules/`。**
本文件覆盖 **Web + API + 手机 + IM + 库表 + 密钥 + 发版**。不是只写手机端。
**含生产密钥,不要推到公开 git。**
---
## 0. 你接手后立刻要知道的事
1. 这是一家约四十人的工程/软件公司内部 OA,从旧 Java OA(生产 MySQL 库 `oa`)迁到本仓库。
2. 用户同时用 **网页** `https://oa.fysxkj.com`**安卓包**。IM 是自研 TCP,不是环信。
3. 办公模块有固定产品规则(第 3 节)。乱改列表筛选或审批不写下游,会被用户打回。
4. 2026-09-01 的 API 数据修复、旧终止投标恢复兼容和项目类型修复已经发到生产;精确结果与备份位置见 `/opt/HANDOFF.md`。后续不要依据本文件旧版本描述判断是否已部署。
5. 回复用户用**简体中文**。不要主动 `git commit` / `push`,除非用户说。
6. 改菜单后必须跑 `apps/api/prisma/sync-menus.ts`,侧栏才更新。
7. 手机登录/刷新必须带 `clientKind: 'mobile'`
---
## 1. 产品与架构
一套 API,三端消费:
| 端 | 路径 | 技术 | 入口 |
|---|---|---|---|
| API | `apps/api` | NestJS 11 + Prisma 6 + PostgreSQL | `/api/v1`Swagger `/api/v1/docs` |
| Web | `apps/web` | React 18 + Vite 5 + Ant Design 5 + React Query + Zustand | 路由 `apps/web/src/App.tsx` |
| 手机 | `apps/native` | Flutter,包名 **必须** `com.fengying.oa.mobile` | 底栏:消息 / 工作台 / 通讯录 / 我 |
| IM | `apps/im-server/server.js` | Node TCP 8901 + 本机 HTTP 8902 | MobileIMSDK 协议 |
生产:
- 站点:`https://oa.fysxkj.com`
- REST`https://oa.fysxkj.com/api/v1`
- IM TCP`47.96.23.244:8901`
- APK`https://oa.fysxkj.com/fengying-oa.apk`
- 远端代码:`/data/fengying-oa/`
- systemd`fengying-api.service``fengying-im.service`(模板在 `infra/fengying-cloud/`
- 生产 API 端口 **3010**nginx 反代);本机 nest 常在 **3000**
本机构建机 hostname `fykj`,公网不是 `47.96.23.244`
租户字段 `tenantId` 目前固定 `'1'`,没有做多租户产品化。
---
## 2. 仓库地图
```
/opt
├── DELIVERY.md ← 本文件
├── .cursor/rules/office-process.mdc
├── apps/api Nest 后端
│ ├── prisma/schema.prisma 库表
│ ├── prisma/menus.ts 侧栏菜单源
│ ├── prisma/seed.ts 演示数据
│ ├── prisma/sync-menus.ts 菜单写入 Permission
│ ├── src/*.controller.ts 业务入口
│ ├── data/app-release.json 手机 OTA
│ └── .env 本机密钥
├── apps/web/src
│ ├── App.tsx 全部路由
│ ├── pages/ 业务页
│ ├── store/auth.ts 登录态
│ ├── api/client.ts axios + sessionStorage
│ └── labels.ts 枚举中文
├── apps/native Flutter
├── apps/im-server/server.js
└── infra/fengying-cloud nginx / systemd / compose 模板
```
本机常用:
```bash
cd /opt/apps/api && npx nest start # http://localhost:3000/api/v1
cd /opt/apps/web && pnpm dev # http://localhost:5173
cd /opt/apps/im-server && node server.js # TCP 8901
cd /opt/apps/native && flutter build apk --release && bash scripts/publish-apk.sh
```
改 Prisma`cd /opt/apps/api && npx prisma migrate dev`
改菜单:改 `prisma/menus.ts``npx ts-node --transpile-only prisma/sync-menus.ts`
---
## 3. 硬约束(违反就是返工)
### 3.1 个人办公怎么长
规则文件:`.cursor/rules/office-process.mdc`
- 列表筛选用 **`ApplyBucketBar`**Segmented + `?bucket=`),有选中态。
- 标准桶:全部 / 待审批 / 已通过 / 已驳回。领域附加桶可以加,不能替代标准桶。
- 禁止页头三个无选中态的 `Link`(「全部」「待审批」「外带未还」这种)。
- 「我的申请」=`/office/flow` 是**跟踪**;人事/用章/办公申请是**发起**;「待我审批」是**审批**。不要做成三套互不相通的列表。
- 申请人页只看自己的单;台账在业务模块(用章登记、资产管理、采购申请)。
审批通过必须写下游:
| 申请 | 落到 |
|---|---|
| 资产购置 `ASSET_BUY` | 采购单 + 待入库台账 + 付款待办 |
| 资产领用 / 用车 | 占用记录(挂台账 id),**交回不改保管人** |
| 出差(人事) | 差旅报销草稿 |
| 用章外带 | 归还待办 |
保管人 = 台账责任人。占用 = 谁正在用。占用时不要把保管人改成持有人。
实现:`apps/api/src/office/office-apply.ts`
### 3.2 登录会话
`apps/api/src/auth/auth.service.ts` + Web `apps/web/src/api/client.ts`
- Web 和手机**可以同时在线**。
- 同一账号不能开两个 Web(后登挤掉先登)。JWT 含 `sid`
- 手机 `clientKind: 'mobile'`refresh **不轮换**access 7 天,refresh 3650 天,除非退出一直在线。
- Web token 用 **sessionStorage** + BroadcastChannel(同浏览器多标签共享)。关浏览器再开要重登。
- 桌面端只能扫码,**不能账密**。`clientKind: 'desktop'` 账密会被拒绝。页 `/login/desktop`;手机「我 → 确认电脑登录」;Deep Link `fengyingoa://qr`
- 登出不带 refresh 时只撤 web/desktop,不踢手机。
### 3.3 安卓
- `applicationId``com.fengying.oa.mobile``namespace` 仍是 `com.fysxkj.fengying_oa`,不要改混。
- 当前 release 用 **debug 签名**。华为控制台正式 SHA256 对不上 debugHMS token 可能拿不到。
- **不要**加 `com.huawei.agconnect` Gradle 插件(AGP 8.6 会炸)。HMS 依赖 + `agconnect-services.json` + Manifest `appid` 留着即可。
### 3.4 数据范围
`AuthUser.dataScope``ALL` / `DEPT` / `SELF`。列表过滤在 `apps/api/src/common/data-scope.ts`。改列表不要只改前端。
---
## 4. 角色、权限、菜单
菜单源:`apps/api/prisma/menus.ts`,写入 `Permission``permType=MENU`)。
`admin` / `owner` 前端 `canSee` 直接放行全部。其他人比 `permissions` 前缀。
种子角色(`prisma/seed.ts`,生产还会有导入岗位):
| code | 名称 | dataScope | 大致权限 |
|---|---|---|---|
| admin | 系统管理员 | ALL | * |
| owner | 企业负责人 | ALL | * |
| biz_director | 商务总监 | DEPT | office/party/seal/bid/contract/finance/report |
| biz_staff | 商务专员 | SELF | office/party/seal/bid/contract |
| tech_director | 技术总监 | DEPT | office/bid/project/report |
| rd_director / 3d_director / video_director / material_director | 各技术条线总监 | DEPT | 见 `role-groups.ts` |
| finance | 财务 | ALL | office/finance/contract/report |
| hr | 人事 | ALL | office/hr + 人事设置 |
| employee / pm / 各 *\_staff | 员工/项目经理 | SELF | office + 工时等 |
| seal / asset / cashier … | 导入岗位 | 视脚本 | `fix-oa-posts.ts` |
总监集合:`DIRECTOR_ROLES`。可发短信:`SMS_SEND_ROLES`。可派工作:`WORK_ASSIGN_ROLES`
`bid-workflow.ts` 仍保留旧的 `SPECIAL_COORDINATORS` 常量用于兼容,但当前特殊筛选/特殊制标的办理人加载逻辑是全体在职人员,禁止再改回写死两人。
首次登录非 admin:改密 + 完善档案(`/office/onboard`)。`mustChangePassword` / `profileCompletedAt`
Web 侧栏:`AppLayout``/auth/me` 带回的 `menus`
发公告:`hr` 与各总监(`NOTICE_PUBLISH_ROLES` in `App.tsx`)。
---
## 5. Web 功能全表(按侧栏)
路由全部在 `apps/web/src/App.tsx`。没有单独的移动端 Web。
### 5.1 工作台 `/workbench`
`OverviewPage`:待办/待审批数字、天气问候、快捷入口。API:`GET /office/overview``GET /office/weather`
### 5.2 个人办公
| 菜单 | 路径 | 页面 | API 要点 | 说明 |
|---|---|---|---|---|
| 待办事项 | `/office/todos` | OfficeTodosPage | `GET /office/todos` `PATCH /office/todos/:id` | 待办可标已办 |
| 待我审批 | `/office/approvals` | OfficeApprovalsPage | `GET /office/approvals` `POST .../decide` | 统一审批入口 |
| 我的申请 | `/office/flow` | MyFlowPage | `GET /office/flow` | 只跟踪自己的单 |
| 我的日程 | `/office/calendar` | CalendarPage | `GET /office/calendar` | 含证照/印章逾期、开标等 |
| 工作汇报 | `/office/reports` | WorkReportPage | `/office/reports*` | 可从工时草稿带入 |
| 我的费用 | `/office/expenses` | MyExpensesPage + Expense/Loan 详情 | `/office/my-expenses` `/expenses` `/loans` | 员工发起报销/借款 |
| 人事申请 | `/office/hr-apply` | HrApplyPage / HrLeaveDetailPage | `/leave-requests` | 请假加班出差外出离职晋升 |
| 用章申请 | `/office/seal-apply` | SealRequestListPage mode=apply | `/seal-requests` | 发起;台账在证照用章 |
| 办公申请 | `/office/apply` | OfficeApplyPage | `/office/applies` | 购置/领用/用车 |
| 公司公告 | `/office/notices` | NoticePage / NoticeDetailPage | `/office/notices` | 员工阅读 |
| 发送短信 | `/office/sms` | OfficeSmsPage | `/sms/*` | 总监/人事/admin |
| 工作安排 | `/office/assign` | WorkAssignPage | `/office/work-assignments` | 总监派任务 |
| 消息 | `/office/im` | ImPage | `/im/*` | Web IM + 通话弹层 |
| 员工通讯 | `/office/directory` | DirectoryPage | `/staff` `/directors` | 点人可开聊 |
出差通过后应落差旅报销草稿(人事申请下游)。
### 5.3 往来单位
| 菜单 | 路径 | 页面 | API |
|---|---|---|---|
| 招标单位 | `/party/tenders` | TenderPartyPage | `/parties` |
| 参与主体 | `/party/entities` | LegalEntityPage | `/legal-entities` |
| 联系名录 | `/party/contacts` | ContactDirectoryPage | `/contacts` |
本机已洗过 5 家主体:随行科技(默认,历史投标多挂这里)、风影软件、风影工程、山东鲁兵、隆创。脚本 `prisma/wash-legal-entities.ts`。OA dump 几乎没有「软件/工程」历史投标,不会自动拆单。
### 5.4 证照用章
| 菜单 | 路径 | 页面 | API |
|---|---|---|---|
| 资质台账 | `/seal/qualifications` | QualificationListPage | `/qualifications` |
| 证照借用 | `/seal/borrows` | BorrowListPage | `/credential-borrows` |
| 用章登记 | `/seal/requests` | SealRequestListPage mode=register | `/seal-requests` |
| 印章台账 | `/seal/registry` | SealRegistryPage | `/seals` |
印章状态:`IN_STOCK` 在库,`OUT` 外带(Web 台账有特判)。外带审批通过写归还待办。
### 5.5 投标管理(核心)
状态机:`apps/api/src/bidding/bid-workflow.ts`
列表桶:`BidListPage` + `?bucket=`
```
普通 FLOW
项目筛选 TECH_INITIAL → FETCH → TECH_FINAL
→ 标书制作 DRAFTING → REVIEW1 → REVIEW2 → PRINT
→ 投标执行 LIST → PENDING
→ 开标结果 WON / LOST / FAILED
随时可 TERMINATED(权限内)
特殊 SPECIAL:特殊筛选 / 特殊制标的办理人从**全公司在职人员**中选择,不再写死刘运泽、陈铖。
```
| 菜单 | 路径 | bucket |
|---|---|---|
| 项目筛选 | `/bid/screening` | screening(可新建) |
| 特殊筛选 | `/bid/special-screening` | 特殊 |
| 标书制作 | `/bid/drafting` | drafting |
| 特殊制标 | `/bid/special-drafting` | drafting+special |
| 投标执行 | `/bid/execution` | execution |
| 开标结果 | `/bid/result` | result |
| 终止投标 | `/bid/terminated` | terminated |
详情 `/bid/cases/:id``BidDetailPage`。新建:`BidCreatePage`
中标:`ensureWonLedger` 立刻落 **合同草稿 + 项目台账**(幂等)。
未中/终止:可「编辑」登记对方中标单位/金额 → 报表「落标对照」。
API`/bid-cases``PATCH /bid-cases/:id/competitor`
### 5.6 合同管理
中标自动生成 `kind=TENDER` 草稿。直签/其他在本页新建,不互相重复。
| 菜单 | 路径 | 说明 |
|---|---|---|
| 投标合同 | `/contract/tender` | 中标来的 |
| 直签合同 | `/contract/direct` | 不经投标 |
| 其他合同 | `/contract/incremental` | 其余 |
| 人事合同 | `/contract/hr` | `LaborContractPage` `/labor-contracts` |
详情 `/contract/:id`。API `/contracts`
### 5.7 资产管理
数字资产 `/asset/digital`、固定资产 `/asset/fixed``AssetRegistryPage``/registered-assets`
状态含 `IN_STOCK` / `PENDING_IN` / 占用。入库:`POST /registered-assets/:id/inbound`
领用占用走办公申请,不在本页把保管人改掉。
### 5.8 项目管理
中标自动写入台账。直签可在台账补登。
| 菜单 | 路径 | API |
|---|---|---|
| 项目台账 | `/project/list` | `/projects` |
| 任务分解 | `/project/tasks` | `/project-tasks` |
| 工时填报 | `/project/timesheets` | `/timesheets` |
| 资源看板 | `/project/resources` | OpsPages ResourceBoardPage |
| 项目验收 | `/project/acceptance` | 同一 ProjectListPage mode=acceptance |
| 采购申请 | `/project/purchases` | PurchasePage(购置审批下游) |
| 器材占用 | `/project/assets` | AssetPage 占用记录 |
详情 `/project/:id`。预算/报销:商务差旅进商务,技术部门差旅/工时/采购进技术(页面 description 已写)。
### 5.9 财务收支
员工在「我的费用」发起;财务在本模块确认、付款、开票。草稿财务看不到(`canSeeSubmittedFinance`)。
| 菜单 | 路径 | API |
|---|---|---|
| 费用报销 | `/finance/expenses` | `/expenses` |
| 借支记录 | `/finance/loans` | `/loans` |
| 投标/履约/质保金 | `/finance/bonds/*` | `/bonds` type=TENDER/PERFORMANCE/WARRANTY |
| 开票收款 | `/finance/invoices` | `/invoices` |
| 付款执行 | `/finance/payments` | `/payments` |
报销必须挂费用对象:投标 / 正式项目 / 部门 / 行政,规则 `assertCostObject`。中标前挂投标,中标后挂项目,不要同时挂。
### 5.10 组织人事
| 菜单 | 路径 | 说明 |
|---|---|---|
| 在职员工 | `/hr/employees` | 档案、证件、银行卡 |
| 组织管理 | `/hr/org` | 部门树 |
| 入转调离 | `/hr/lifecycle` | EmploymentEvent |
| 假勤结果 | `/hr/attendance` | 月导入 + 打卡 |
| 薪酬核算 | `/hr/payroll` | 核算批次 |
| 绩效发放 | `/hr/performance` | |
| 发布公告 | `/hr/notices` | 人事发 |
| 规章制度 | `/hr/policies` | 同类 Noticekind 不同 |
### 5.11 经营报表
| 菜单 | 路径 | API |
|---|---|---|
| 投标转化 | `/report/conversion` | `/reports/bid-conversion` |
| 落标对照 | `/report/lost` | `/reports/competitor-wins` |
| 项目利润 | `/report/profit` | `/reports/profit` |
| 部门人效 | `/report/efficiency` | `/reports/efficiency` |
| 资金占用 | `/report/cash` | `/reports/cash` |
页面在 `OpsPages.tsx`
### 5.12 系统设置
| 菜单 | 路径 | 说明 |
|---|---|---|
| 员工账号 | `/system/acl` | 用户/角色 |
| 人事参数 | `/system/hr-params` | |
| 考勤设置 | `/system/attendance-rules` | |
| 数据字典 | `/system/dicts` | |
| 权限设置 | `/system/perms` | 菜单授权 |
| 编号规则 | `/system/numbers` | `nextBizNo` |
| 短信服务 | `/system/sms` | 签名/模板/绑定事件 |
| 消息通道 | `/system/messages` | 占位页,文案可能过时(IM 已做) |
| 手机端升级 | `/system/app-release` | 写 `app-release.json` |
| 操作日志 | `/system/logs` | |
公开:`GET /system/app-release`(手机 OTA)。
---
## 6. 核心流程(改业务先对这条链)
### 投标 → 合同/项目
筛选 → 取标 → 终审 → 制标 → 一审二审 → 印封 → 执行 → 开标。
**WON** → 合同草稿 + 项目台账。售前报销仍挂投标;正式花费挂项目。
### 报销 / 借款
员工草稿 → 提交 PENDING → 财务/总监审批 APPROVED → 付款执行。借款还有 OPEN / OFFSET 冲账。
### 办公申请
发起 PENDING → 审批(admin/owner/hr/biz_director)→ 按 kind 写采购/占用/待入库。
### 用章
申请 → 审批 → 外带则归还待办;台账 status 在库/外带。
### 人事假勤
`LeaveRequest.kind`LEAVE / OVERTIME / TRIP / OUT / RESIGN / PROMOTE。出差通过 → 报销草稿。
统一待办 `TodoTask`、统一审批 `ApprovalTask``bizType` 区分投标/合同/报销/用章/人事/办公)。
待我审批一页消化,不要为每个 biz 再做孤立审批页。
---
## 7. API 地图
前缀均 `/api/v1`。鉴权 JWT,除标注 Public。
| 模块 | 文件 | 主要路径 |
|---|---|---|
| 登录 | `auth/` | `/auth/login` `/auth/refresh` `/auth/me` `/auth/logout` `/auth/profile` `/auth/password` `/auth/qr/*` |
| 办公 | `office/` | `/office/*` 待办审批日程公告申请汇报工作安排天气地理 |
| 投标 | `bidding/` | `/bid-cases` |
| 合同 | `contract/` | `/contracts` |
| 项目 | `project/` | `/projects` `/project-tasks` `/timesheets` |
| 用章 | `seal/` | `/seal-requests` `/seals` `/qualifications` `/credential-borrows` |
| 财务 | `finance/` | `/expenses` `/loans` `/bonds` `/invoices` `/payments` |
| 人事 | `hr/` | `/employees` `/leave-requests` `/payroll` `/attendance` `/labor-contracts` `/org` |
| 主数据 | `master/` | `/parties` `/legal-entities` `/contacts` `/registered-assets` |
| 系统 | `system/` | `/system/menus` `/system/acl` `/system/dicts` `/system/numbers` `/system/app-release` `/system/logs` |
| 报表 | `report/` | `/reports/*` |
| 文件 | `files/` | `POST /files` `GET /files/:id` |
| IM | `im/` | `/im/conversations` `/im/messages` `PATCH /im/messages/:id` `/im/calls*` `/im/read` |
| 推送 | `push/` | 设备 token 注册与下发 |
| 短信 | `sms/` | 签名模板发送 OTP |
| 通讯录 | `business/` | `/staff` `/directors` |
| 健康 | `health/` | `/health` |
| 协议 | `legal/` | 用户协议页 |
响应外包一层 `{ data }`Web 用 `unwrapList` / `body.data`。手机 `oa_client.dart` 同样解包。
错误:`HttpErrorFilter`。请求号:`X-Request-Id`
地理:`/office/geo/*` 走腾讯位置服务,`TENCENT_MAP_KEY`
---
## 8. 数据模型(Prisma
完整定义:`apps/api/prisma/schema.prisma`
**账号**User、RefreshToken`clientKind`)、AuthQrTicket、Role、Permission、UserRole、PushDevice
**组织人事**Department、Employee、LaborContract、EmploymentEvent、AttendanceMonth、AttendancePunch、LeaveRequest、HrSetting、AttendanceRule、PayrollRun、PayrollItem
**主数据**LegalEntity、BusinessParty、PartyContact、DictItem、NumberRule、Qualification、SealItem、RegisteredAsset
**投标**BidCase(含 `winnerName/Amount/Note`)、BidReviewer、BidAssignee、BidDocVersion、BidActionLog
**合同项目**Contract、ContractReviewer、PaymentMilestone、Project、ProjectBudget、ProjectTask、Timesheet、ProjectAcceptance、ProjectAcceptMaterial、ProjectChange
**财务**ExpenseClaim、ExpenseLine、LoanRecord、BondRecord、InvoiceRecord、PaymentRecord、PurchaseRequest
**办公**TodoTask、ApprovalTask、WorkReport、WorkAssignment、OfficeApply、Notice、AssetOccupancy、CredentialBorrow、SealRequest
**IM**ImConversation、ImConversationMember、ImMessage`fingerprint` 唯一)、ImReadCursor、ImMessageReceipt、ImCall、ImCallMember、ImCallSignal
**其它**FileAsset、SmsSendLog、SmsEventBind、SmsOtp、OperationLog
编号:`NumberRule` + `src/system/numbering.ts`。注意:**项目筛选、特殊筛选的招标编号和合同编号必须手填,系统不得另生成隐藏编号**;报销等内部单据仍可使用系统编号。
---
## 9. 手机端
Flutter。版本:`apps/native/lib/app_config.dart` + `pubspec.yaml` + `test/widget_test.dart` + `apps/api/data/app-release.json`,四者一起改。
默认连生产 API / IM。底栏四页:`messages_page``workbench_page``directory_page``mine_page`
工作台宫格来自登录菜单。`open_module.dart` / `module_list_page.dart` 把菜单码映射到 REST 列表(投标→`/bid-cases` 等)。系统设置、发短信、工作台里的「消息」入口故意不打开(消息在底栏)。详情 `record_detail_page.dart` + `biz_route.dart` 反查详情 API。中文:`labels.dart`
聊天/通话的关键文件:聊天 `chat_page.dart`,来电 `home_shell.dart` + `call_page.dart`。当前版本为 3.1.8+318;音视频、截屏提醒、撤回再编辑、会话已读后保留等需求必须按 `/opt/HANDOFF.md` 的“未完成/需真机回归”继续验证,不能仅凭旧交接宣称已修。
打包:
```bash
cd /opt/apps/native && flutter build apk --release && bash scripts/publish-apk.sh
```
产物:`/opt/apps/native/dist/fengying-oa.apk`。本机 root 跑 Flutter 会警告,仍能编过。下一版必须在 **3.1.8+318** 基础上递增。
OTA:比较 `build`。后台「手机端升级」。
---
## 10. IM
协议:4 字节大端长度 + UTF-8 JSON Protocal。登录用 OA accessToken 调 `GET /auth/me`
**新 `server.js`**`Map<userId, Set<socket>>`,同一账号多端/重连 **不再 kick**。旧版会踢旧连接,客户端若把 `_want=false` 会永久断线。
生产必须换新 `server.js` 长连接才稳。新 APK 即使对旧 IM 也不会再「被踢后永不再连」。
HTTP 消息仍落 Postgres。TCP 负责推送。通话信令走 `/im/calls/:id/signal`HTTP 轮询),WebRTC + 国内 STUN。
`PATCH /im/messages/:id`2 分钟内改自己的文本。
---
## 11. 文件、短信、推送、地图
- 文件:`FilesService` 存 MinIO(本机 docker)或本地 `uploads/`。下载带鉴权。手机 `AuthImage` / `fileBytes` 有内存缓存。
- 短信:阿里云 Dysmsapi。系统设置申请签名/模板,事件绑定在 `SmsEventBind`。投标节点会 `emitSms`
- 推送:vivo / OPPO / 魅族 / 华为。设备 `PushDevice`。华为 App ID `118301849`
- 地图:腾讯。选点 `location_pick_page.dart` + `tencent-lbs`;POI 用真实距离,不要写死「100m内」。
---
## 12. 前端约定
**Web**
- 新列表:Ant Design Table + 中文 `zh()` / `StatusTag`,不要直接渲染枚举码。
- 申请类列表:`ApplyBucketBar` + URL `bucket`
- 抽屉表单:`FormDrawer.tsx`。附件:`FileAttachments.tsx`
- 枚举中文:`apps/web/src/labels.ts`,与手机 `labels.dart` 对齐。
- token 只放 sessionStorage。
**手机**
- 企业微信风:`theme.dart``widgets/wecom.dart`
- 英文字段:补 `fieldZh` / `zhMap`,不要在单个页写死。`OUT` 人事=外出、印章=外带,有冲突时按字段上下文。
---
## 13. 脚本与历史数据
`apps/api/prisma/` 里常见脚本(跑前看文件头,生产库只读):
| 脚本 | 用途 |
|---|---|
| seed.ts | 空库演示 |
| sync-menus.ts | 菜单/权限 |
| import-hr.ts / import-prod.ts | 从旧 OA 导人事/业务 |
| wash-legal-entities.ts | 参与主体清洗 |
| wash-oa-ledger.ts / wash-oa-flow-finance.ts | 台账/财务清洗 |
| sync-oa-attachments.ts | 附件 |
| grant-admin-equivalents.ts / ensure-prod-admin.ts | 管理员 |
| fix-oa-posts.ts / fix-expense-applicants.ts | 岗位与报销申请人 |
旧 OA 在生产 MySQL `oa`(只读扒数,用户禁止改生产旧库、禁止整用户过来)。dump 曾放 `/opt/import/prod/`
---
## 14. 密钥与账号(Codex 直接用,不必再问)
### 生产 SSH
```
主机:47.96.23.244
用户:root
密码:VYTnNdfFfrX7xTP
代码目录:/data/fengying-oa/
```
```bash
export SSHPASS='VYTnNdfFfrX7xTP'
RSH='ssh -o StrictHostKeyChecking=no'
DEST=root@47.96.23.244
sshpass -e ssh -o StrictHostKeyChecking=no "$DEST" 'hostname; systemctl is-active fengying-api fengying-im'
```
发版:
```bash
export SSHPASS='VYTnNdfFfrX7xTP'
RSH='ssh -o StrictHostKeyChecking=no'
DEST=root@47.96.23.244
# 先 nest build、确认 APK 已 publish
sshpass -e rsync -az -e "$RSH" /opt/apps/api/dist/ $DEST:/data/fengying-oa/apps/api/dist/
sshpass -e rsync -az -e "$RSH" /opt/apps/api/data/app-release.json $DEST:/data/fengying-oa/apps/api/data/app-release.json
sshpass -e rsync -az -e "$RSH" /opt/apps/web/dist/ $DEST:/data/fengying-oa/apps/web/dist/ # 若本机有最新 web 构建
sshpass -e rsync -az -e "$RSH" /opt/apps/im-server/server.js $DEST:/data/fengying-oa/apps/im-server/server.js
sshpass -e rsync -az -e "$RSH" /opt/apps/native/dist/fengying-oa.apk $DEST:/data/fengying-oa/apps/web/dist/fengying-oa.apk
sshpass -e ssh -o StrictHostKeyChecking=no "$DEST" 'systemctl restart fengying-api fengying-im; sleep 2; systemctl is-active fengying-api fengying-im'
```
生产 API `.env` 在远端 `/data/fengying-oa/apps/api/.env`。上去先 `cat`**不要用本机 development 的 JWT/Postgres 覆盖生产**。发 IM/API 后确认 Prisma 已有 `RefreshToken.clientKind``AuthQrTicket``BidCase.winner*`
### 旧 OA MySQL(只读)
```
用户:root
密码:BtH9ywG8gFTXG9w!
库:oa
```
### 本机 API `apps/api/.env`
```
NODE_ENV=development
APP_PORT=3000
DATABASE_URL=postgresql://ops:ops_dev_only@localhost:5432/enterprise_ops
REDIS_URL=redis://localhost:6379
MINIO_ACCESS_KEY=opsadmin
MINIO_SECRET_KEY=opsadmin_dev_only
JWT_ACCESS_SECRET=change-me-access-secret-dev-only
JWT_REFRESH_SECRET=change-me-refresh-secret-dev-only
POSTGRES_USER=ops
POSTGRES_PASSWORD=ops_dev_only
POSTGRES_DB=enterprise_ops
ALIYUN_SMS_ACCESS_KEY_ID=LTAI5t9DdPycJp7sabQyEWRc
ALIYUN_SMS_ACCESS_KEY_SECRET=RPFVqKDUpEvxo3IsqlxLB5qIywfheM
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_REGION=cn-hangzhou
ALIYUN_SMS_DEFAULT_SIGN=江苏风影随行科技
ALIYUN_RAM_LOGIN=yongkangdai10@1801267455383083.onaliyun.com
TENCENT_MAP_KEY=L6NBZ-47KLI-OL7G2-UMROZ-XPI3T-SLFC7
IM_TCP_PORT=8901
IM_HTTP_PORT=8902
IM_INTERNAL_TOKEN=fengying-im-internal
VIVO_PUSH_APP_ID=106114371
VIVO_PUSH_APP_KEY=b521c805229a00e85a53d4b535a0cafe
VIVO_PUSH_APP_SECRET=2e1536ed-ed2f-4cd6-88d1-67aa9a93eeba
OPPO_PUSH_APP_ID=37438377
OPPO_PUSH_APP_KEY=e23832b52fbb46e18de6eef5affda2e9
OPPO_PUSH_APP_SECRET=e9cf0bf35052484ebcaf15d56576468b
OPPO_PUSH_MASTER_SECRET=e9cf0bf35052484ebcaf15d56576468b
MEIZU_PUSH_APP_ID=157643
MEIZU_PUSH_APP_KEY=4263a8d18de442b6a17203866ce1546d
MEIZU_PUSH_APP_SECRET=2a0bafc2d47a470f8b2aad3f182d2377
HUAWEI_PUSH_APP_ID=118301849
HUAWEI_PUSH_CLIENT_ID=118301849
HUAWEI_PUSH_CLIENT_SECRET=85c01c5c853d571e2d83fd8629efb21e9b2cad0bdfadd6f32c2c5b42e38d7888
```
本机 IM `.env` 写的是 `OA_API_URL=http://127.0.0.1:3010/api/v1`,本机 nest 常在 3000,连本机 IM 请改 3000。生产 systemd`OA_API_URL=http://127.0.0.1:3010/api/v1`
### 华为 AGC
- 包名 `com.fengying.oa.mobile`App ID `118301849`
- 服务端 Client Secret`85c01c5c853d571e2d83fd8629efb21e9b2cad0bdfadd6f32c2c5b42e38d7888`
- JSON`apps/native/android/app/agconnect-services.json`
- JSON `client.client_id``1993069421077225280`
- JSON `client.client_secret``93585E101B911EFF0F99F215050CD3E4297F67CB004E0D8E070AAB1EEDD7E725`
- JSON `client.api_key``DgEDAFLpMeUxEsIEiKams+kYWThvAg0ioMuL1rW9BEP4i71BuChEPD7Udg1AbDKbg+ljwD+pBACxktLqHwmUlSxOCwsLHiRAKQDBFw==`
- `cp_id``10086000847744494``product_id``101653523864520415`
### OPPO Maven
```
https://maven.columbus.heytapmobi.com/repository/releases/
username: nexus
password: c0b08da17e3ec36c3870fed674a0bcb36abc2e23
```
### 腾讯地图 / vivo Manifest
- `TENCENT_MAP_KEY=L6NBZ-47KLI-OL7G2-UMROZ-XPI3T-SLFC7`
- Manifest vivo`api_key=b521c805229a00e85a53d4b535a0cafe``app_id=106114371`
### 本机演示账号
`admin` / `Admin@123456`。种子其他人常见 `Demo@123456` 或导入后 `123456`。不要拿去撞生产。
---
## 15. 发生产清单
1. `npx nest build`,同步 `apps/api/dist/`
2. 若改了 Web`pnpm --filter web build`,同步 `apps/web/dist/`
3. 同步 `server.js`、APK、`app-release.json`
4. `systemctl restart fengying-api fengying-im`
5. 后台确认手机端升级为当前最新 build(现为 318)
6. 真机覆盖安装当前最新版(现为 3.1.8)
**APK 保留规则:**云主机只保留当前 OTA 指向的最新安装包(对外统一为 `apps/web/dist/fengying-oa.apk`)。每次发版先精确列出 `/data/fengying-oa` 下的 `.apk` / `.aab`,确认新包可下载后删除其他旧包,不要在 `apps/native/dist` 等目录留历史副本。
API/IM/APK 是否为同一版必须分别核对,不能只看网页或 OTA JSON。
---
## 16. 已知坑 / 未完成
1. 生产 API 已同步 2026-09-01 的数据修复代码;Web、IM、APK 后续每次仍要分别核对版本和构建时间。
2. 华为正式推送:缺正式签名 + AGConnect 插件未进构建。
3. `OUT` 中文:人事外出 vs 印章外带。
4. 系统设置「消息通道」文案还写「本期不做原生 IM」,已过时。
5. Web IM 与手机 IM 都可用,但通话依赖双方打开页 + STUN,失败别先改来电弹层。
6. 手机工作台对报表/部分系统页映射为 `null`,点了可能没页。
7. 打开 docx 依赖手机已装 WPS/Office。
8. 历史投标主体不会自动拆到软件/工程公司。
9. `apps/native/README.md`、部分 api README 版本号过时,以 `app_config.dart` 和本文件为准。
10. 薪酬/绩效/资源看板有页面,深度以现网数据为准,缺的是补流程不是再铺空壳。
11. 问题视频:`/opt/问题视频/`。来电测试对象常为「戴永康」。
---
## 17. 建议接着做的顺序
1. 用第 14 节发生产(API + Web + IM + APK + OTA),不要再问密码。
2. 真机覆盖 2.3.2:来电能关、消息上屏、图片/地图能点、印章「在库」。
3. 网页重新登录:确认扫码登电脑、Web 互踢、侧栏「落标对照」。
4. 再改业务:遵守第 3 节办公规则;改投标走 `bid-workflow.ts`,不要只改一个列表页。
5. 英文残留:补 `labels.ts` / `labels.dart`
6. 华为推送真机(正式签名)。
7. 用户新需求再开功能,不要平行重构目录。
改完菜单同步 `sync-menus.ts`。改完枚举两边 labels 一起改。改完版本四个文件一起改。
+14
View File
@@ -0,0 +1,14 @@
up:
docker compose up -d
migrate:
cd apps/api && pnpm exec prisma migrate dev --name init
seed:
cd apps/api && pnpm prisma:seed
api:
cd apps/api && pnpm start:dev
web:
cd apps/web && pnpm dev
+23
View File
@@ -0,0 +1,23 @@
# 企业综合运营管理平台
内部使用。先上 Web,手机和桌面端以后再说。
## 启动
```bash
cp .env.example .env
docker compose up -d
cd apps/api && pnpm exec prisma migrate dev --name init && pnpm prisma:seed && pnpm start:dev
# 另一个终端
cd apps/web && pnpm dev
```
打开 http://localhost:5173 ,账号 `admin` / `Admin@123456`
Swaggerhttp://localhost:3000/api/v1/docs
## 模块
个人办公、往来单位、证照用章、投标管理、合同管理、项目管理、财务收支、组织人事、经营报表、系统设置。
投标筛选可以新建。其余页面已接通列表接口,数据会随使用增加。
+13
View File
@@ -0,0 +1,13 @@
# 风影办公 Windows 3.1.7 构建
在 Windows PowerShell 中解压源码后执行:
```powershell
flutter config --enable-windows-desktop
flutter pub get
flutter build windows --release
```
产物目录:`build\windows\x64\runner\Release\`。发布时请连同该目录内 DLL 和 `data` 目录一起分发,不能只复制 exe。
本版本的 Android 与 Windows 共用 Flutter 业务代码,包含通话状态同步和工作台业务卡片转发功能。
+11
View File
@@ -0,0 +1,11 @@
# 风影办公 Windows 3.1.7 构建
在 Windows PowerShell 中解压源码后执行:
```powershell
flutter config --enable-windows-desktop
flutter pub get
flutter build windows --release
```
产物目录:`build\\windows\\x64\\runner\\Release\\`。发布时请连同该目录内 DLL 和 `data` 目录一起分发,不能只复制 exe。
+1
View File
@@ -0,0 +1 @@
{"agcgw":{"url":"connect-drcn.dbankcloud.cn","backurl":"connect-drcn.hispace.hicloud.com","websocketurl":"connect-ws-drcn.hispace.dbankcloud.cn","websocketbackurl":"connect-ws-drcn.hispace.dbankcloud.com"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864520415","client_id":"1993069421077225280","client_secret":"93585E101B911EFF0F99F215050CD3E4297F67CB004E0D8E070AAB1EEDD7E725","project_id":"101653523864520415","app_id":"118301849","api_key":"DgEDAFLpMeUxEsIEiKams+kYWThvAg0ioMuL1rW9BEP4i71BuChEPD7Udg1AbDKbg+ljwD+pBACxktLqHwmUlSxOCwsLHiRAKQDBFw==","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"118301849","client_type":1},"app_info":{"app_id":"118301849","package_name":"com.fengying.oa.mobile"},"service":{"analytics":{"collector_url":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drcn.ai.dbankcloud.com,ml-api-drcn.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drcn.cloud.huawei.com"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"CN","configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"app_id":"118301849"},"oauth_client":{"client_id":"118301849","client_type":1},"app_info":{"app_id":"118301849","package_name":"com.fengying.oa.mobile"}}]}
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+47
View File
@@ -0,0 +1,47 @@
# 企业综合运营管理平台
内部运营系统。当前先交付 **Web 端**,手机端和桌面端后续用同一套 API 再做。
## 技术栈
- 后端:NestJS 11 + Prisma 6 + PostgreSQL 16
- 前端:React 18 + Vite 5 + Ant Design 5
- 依赖:Redis、MinIO(本地 Docker
## 目录
```
apps/api 后端
apps/web Web
infra/ 预留脚本
docs/ 架构说明
```
## 启动
```bash
cp .env.example .env
docker compose up -d
pnpm --filter api exec prisma migrate dev --name init
pnpm --filter api prisma:seed
pnpm --filter api start:dev
pnpm --filter web dev
```
- Webhttp://localhost:5173
- APIhttp://localhost:3000/api/v1
- Swaggerhttp://localhost:3000/api/v1/docs
演示账号:`admin` / `Admin@123456`
其他人:`owner` `bizdir` `bizstaff` `dirsoft` `dir3d` `dirfilm` `dirmaterial` `finance` `hr` `employee`,密码均为 `Demo@123456`
## 测试
```bash
pnpm --filter api test
pnpm --filter api test:e2e
```
## 范围
本期把十个一级模块和二级菜单全部铺到 Web 上,登录、权限、投标筛选可写。合同、工时、薪酬等以台账和空数据为主,后续按模块补流程。不含外包、线索、仓库,也不做原生 IM。
+12
View File
@@ -0,0 +1,12 @@
{
"platform": "android",
"version": "3.1.10",
"build": 324,
"url": "https://oa.fysxkj.com/fengying-oa.apk",
"windowsUrl": "https://oa.fysxkj.com/fengying-oa-setup.exe",
"linuxUrl": "https://oa.fysxkj.com/fengying-oa-linux.tar.gz",
"linuxDebUrl": "https://oa.fysxkj.com/fengying-oa_amd64.deb",
"changelog": "桌面端功能补全:语音播放/录制/转写、位置/转发/编辑、工作台模块路由、Linux deb 安装包(Ubuntu/Debian/Deepin25)、OTA 修复。",
"force": false,
"publishedAt": "2026-09-02T07:15:00.000Z"
}
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node --transpile-only prisma/seed.ts"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.6.0",
"@alicloud/openapi-client": "^0.4.15",
"@alicloud/openapi-core": "^1.0.8",
"@alicloud/tea-util": "^1.4.11",
"@darabonba/typescript": "^1.0.5",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.0",
"argon2": "^0.45.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.3.0",
"ioredis": "^6.0.0",
"livekit-server-sdk": "^2.18.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "6.19.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"pnpm": {
"onlyBuiltDependencies": [
"argon2",
"@prisma/client",
"prisma",
"@prisma/engines"
]
},
"prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts"
}
}
+7234
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import { parseOaJson } from './oa-json';
const prisma = new PrismaClient();
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function compactName(name: string) {
return name.replace(/[\s()()【】\[\]《》·,,。.\-_/\\]/g, '').toLowerCase();
}
function namesOverlap(a: string, b: string) {
const na = compactName(a);
const nb = compactName(b);
if (!na || !nb) return false;
if (na === nb) return true;
const shorter = na.length <= nb.length ? na : nb;
const longer = na.length <= nb.length ? nb : na;
return shorter.length >= 8 && longer.includes(shorter);
}
async function linkContracts() {
const bids = await prisma.bidCase.findMany({
select: { id: true, bidNo: true, externalNo: true, name: true },
});
const contracts = await prisma.contract.findMany({
where: { bidCaseId: null },
select: { id: true, name: true, contractNo: true },
});
let linked = 0;
for (const c of contracts) {
const no = c.contractNo || '';
const tb = no.match(/TB-\d+-\d+/);
const hits = bids.filter((b) => {
if (tb && (b.bidNo === tb[0] || b.bidNo.includes(tb[0]) || (b.externalNo && b.externalNo.includes(tb[0])))) {
return true;
}
if (no && (b.bidNo && no.includes(b.bidNo))) return true;
return namesOverlap(c.name, b.name);
});
const unique = [...new Map(hits.map((h) => [h.id, h])).values()];
if (unique.length !== 1) continue;
await prisma.contract.update({ where: { id: c.id }, data: { bidCaseId: unique[0].id } });
linked += 1;
}
return { scanned: contracts.length, linked };
}
async function backfillSealApplicants() {
const dumps = ['/opt/import/prod/oa_fresh.json', '/opt/import/prod/oa_core.json', '/opt/import/prod/oa_clean.json'];
const dump = dumps.find((p) => fs.existsSync(p));
if (!dump) return { updated: 0, skipped: 'no dump' };
const raw = parseOaJson<Record<string, unknown>>(dump);
const hrPath = '/opt/import/prod/oa_hr.json';
const hr = fs.existsSync(hrPath) ? parseOaJson<{ users?: Record<string, unknown>[] }>(hrPath) : { users: [] };
const users = await prisma.user.findMany();
const byUsername = new Map(users.map((u) => [u.username, u]));
const byName = new Map(users.map((u) => [u.displayName, u]));
const admin = users.find((u) => u.displayName === '系统管理员');
const oaUser = new Map((hr.users || []).map((u) => [String(u.id), str(u.userName || u.username)]));
const userFromOaId = (id: unknown) => {
const uname = oaUser.get(str(id));
return uname ? byUsername.get(uname) : undefined;
};
const applies = (raw.oa_seal_apply || raw.oa_seal || []) as Record<string, unknown>[];
const rows = await prisma.sealRequest.findMany({
include: { applicant: { select: { displayName: true } } },
});
let updated = 0;
for (const r of rows) {
if (r.applicant?.displayName && r.applicant.displayName !== '系统管理员') continue;
const reason = r.reason || '';
const created = r.createdAt ? r.createdAt.getTime() : 0;
const hit = applies.find((a) => {
const content = str(a.content || a.apply_subject || a.reason);
if (content && reason && (content === reason || reason.includes(content) || content.includes(reason))) return true;
const t = str(a.create_time);
if (t && created) {
const d = new Date(t.includes('T') ? t : t.replace(' ', 'T'));
return Math.abs(d.getTime() - created) < 2000 && str(a.seal_type || '') === r.sealType;
}
return false;
});
if (!hit) continue;
const who =
byName.get(str(hit.applicant_name) === '张靖妍' ? '张婧妍' : str(hit.applicant_name)) ||
userFromOaId(hit.applicant_id) ||
userFromOaId(hit.create_by) ||
userFromOaId(hit.creator_id) ||
userFromOaId(hit.apply_user);
if (!who || who.id === admin?.id) continue;
await prisma.sealRequest.update({ where: { id: r.id }, data: { applicantId: who.id } });
updated += 1;
}
return { updated, scanned: rows.length };
}
async function main() {
const contracts = await linkContracts();
const seals = await backfillSealApplicants();
const [tender, incremental] = await Promise.all([
prisma.contract.count({ where: { bidCaseId: { not: null } } }),
prisma.contract.count({ where: { bidCaseId: null } }),
]);
console.log(JSON.stringify({ contracts, seals, tender, incremental }, null, 2));
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+127
View File
@@ -0,0 +1,127 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { normalizeProjectType } from '../src/project/project-workflow';
const prisma = new PrismaClient();
function str(v: unknown) {
return v == null ? '' : String(v).trim();
}
function num(v: unknown) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function compact(name: string) {
return String(name || '')
.replace(/[()()\[\]【】\s\-—_.,,、]/g, '')
.replace(/招标公告|采购项目|技术服务合同|合同/g, '');
}
function loadCore(): { project_list?: Record<string, unknown>[]; reimbursement?: Record<string, unknown>[] } {
const candidates = [
join('/opt/import/prod/oa_core.json'),
join(process.cwd(), '../../import/prod/oa_core.json'),
join(process.cwd(), 'import/prod/oa_core.json'),
];
for (const p of candidates) {
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8'));
}
return {};
}
async function main() {
const core = loadCore();
const oldProjects = core.project_list || [];
const reimburses = core.reimbursement || [];
const projects = await prisma.project.findMany({
include: { contract: { include: { bidCase: { select: { projectType: true, name: true } } } }, expenses: { select: { id: true, projectId: true } } },
});
const expenses = await prisma.expenseClaim.findMany({
where: { tenantId: '1' },
select: { id: true, projectId: true, purpose: true, remark: true },
});
let typed = 0;
let amounted = 0;
let linked = 0;
for (const p of projects) {
const old = oldProjects.find((o) => {
const code = str(o.project_code || o.system_number);
const name = str(o.project_name);
return (code && (code === p.projectNo || p.projectNo.endsWith(code))) ||
(name && (name === p.name || compact(name) === compact(p.name)));
});
const nextType =
normalizeProjectType(p.projectType) ||
normalizeProjectType(p.contract?.bidCase?.projectType) ||
normalizeProjectType(str(old?.project_category || old?.project_type));
const nextAmount =
num(p.amount) ||
num(p.contract?.amount) ||
num(old?.project_budget);
const data: Prisma.ProjectUpdateInput = {};
if (nextType && nextType !== p.projectType) {
data.projectType = nextType;
typed += 1;
}
if (nextAmount > 0 && num(p.amount) <= 0) {
data.amount = nextAmount;
amounted += 1;
}
if (Object.keys(data).length) {
await prisma.project.update({ where: { id: p.id }, data });
}
}
const refreshed = await prisma.project.findMany({ select: { id: true, name: true, projectNo: true } });
for (const e of expenses) {
if (e.projectId) continue;
const hit = refreshed.find((p) => {
const blob = `${e.purpose || ''} ${e.remark || ''}`;
return blob.includes(p.projectNo) || blob.includes(p.name) || (p.name.length >= 8 && blob.includes(p.name.slice(0, 12)));
});
const old = reimburses.find((r) => str(r.project_name) && hit && compact(str(r.project_name)) === compact(hit.name));
const byName = refreshed.find((p) => {
const r = reimburses.find((x) => str(x.id) && String(e.remark || '').includes(String(x.id)));
return r ? compact(str(r.project_name)) === compact(p.name) : false;
});
const target = hit || byName;
if (!target) continue;
await prisma.expenseClaim.update({ where: { id: e.id }, data: { projectId: target.id, costType: 'PROJECT' } });
linked += 1;
}
// old reimbursements already imported with project names
for (const r of reimburses) {
const name = str(r.project_name);
if (!name) continue;
const project = refreshed.find((p) => p.name === name || compact(p.name) === compact(name));
if (!project) continue;
const updated = await prisma.expenseClaim.updateMany({
where: {
tenantId: '1',
projectId: null,
OR: [{ purpose: { contains: name.slice(0, 16) } }, { remark: { contains: name.slice(0, 16) } }],
},
data: { projectId: project.id, costType: 'PROJECT' },
});
linked += updated.count;
}
console.log(`project ledger backfill: typed=${typed} amounted=${amounted} expensesLinked=${linked}`);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+79
View File
@@ -0,0 +1,79 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const KEYS = ['ccDaily', 'ccWeekly', 'ccMonthly'] as const;
function rolesOf(emp: { users: { roles: { role: { code: string } }[] }[] }) {
return [...new Set(emp.users.flatMap((u) => u.roles.map((r) => r.role.code)))];
}
function isDirector(emp: { title?: string | null; jobRole?: string | null; users: { roles: { role: { code: string } }[] }[] }) {
if (/总监/.test(`${emp.title || ''}${emp.jobRole || ''}`)) return true;
return rolesOf(emp).some((r) => r.endsWith('_director') || r === 'biz_director' || r === 'owner');
}
function parseIds(raw?: string | null) {
if (!raw) return [];
return [...new Set(raw.split(/[,,、\s]+/).filter((s) => UUID_RE.test(s)))];
}
async function main() {
const depts = await prisma.department.findMany({ where: { tenantId: '1' }, select: { id: true, parentId: true } });
const byId = new Map(depts.map((d) => [d.id, d]));
const people = await prisma.employee.findMany({
where: { tenantId: '1' },
include: { users: { select: { roles: { select: { role: { select: { code: true } } } } } } },
});
const byDept = new Map<string, typeof people>();
for (const e of people) {
const list = byDept.get(e.departmentId) || [];
list.push(e);
byDept.set(e.departmentId, list);
}
function scopeDeptIds(start: string, selfId: string) {
const scope: string[] = [];
const seen = new Set<string>();
let cur: string | null | undefined = start;
while (cur && !seen.has(cur)) {
seen.add(cur);
scope.push(cur);
const hasDir = (byDept.get(cur) || []).some((e) => e.id !== selfId && isDirector(e));
if (hasDir) break;
cur = byId.get(cur)?.parentId;
}
return new Set(scope);
}
let n = 0;
for (const emp of people) {
const scope = scopeDeptIds(emp.departmentId, emp.id);
const dirs = people.filter((d) => scope.has(d.departmentId) && d.id !== emp.id && d.id !== emp.managerId && isDirector(d));
const dirIds = dirs.map((d) => d.id);
const allowed = new Set(dirIds);
const data: Partial<Record<(typeof KEYS)[number], string | null>> = {};
for (const key of KEYS) {
const cur = parseIds(emp[key]);
let next = cur.filter((id) => allowed.has(id));
if (!next.length && dirIds.length && cur.some((id) => id === emp.managerId || !allowed.has(id))) {
next = dirIds;
}
const joined = next.length ? next.join(',') : null;
if (joined !== (emp[key] || null)) data[key] = joined;
}
if (!Object.keys(data).length) continue;
await prisma.employee.update({ where: { id: emp.id }, data });
n += 1;
console.log(`${emp.name}: ${JSON.stringify(data)}`);
}
console.log(`updated ${n} / ${people.length} employee cc fields`);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+36
View File
@@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const techPeople = await prisma.employee.findMany({
where: { department: { deptType: 'TECH' } },
select: { name: true, department: { select: { deptType: true, name: true } } },
});
const names = techPeople.map((e) => e.name).filter((n) => n && n.length >= 2);
const rows = await prisma.expenseClaim.findMany({
where: { tenantId: '1', costType: 'PROJECT', budgetCategory: 'TRAVEL' },
include: {
applicant: { select: { employee: { select: { name: true, department: { select: { deptType: true } } } } } },
lines: { select: { remark: true } },
},
});
let n = 0;
for (const row of rows) {
const dept = row.applicant.employee?.department?.deptType || '';
const text = [row.purpose, row.remark, ...row.lines.map((l) => l.remark)].filter(Boolean).join(' ');
const forTech = names.some((name) => text.includes(name));
if (dept !== 'TECH' && !forTech) continue;
await prisma.expenseClaim.update({ where: { id: row.id }, data: { budgetCategory: 'TECH' } });
n += 1;
}
console.log(`reclassified ${n} / ${rows.length} travel expenses into tech budget; tech staff ${names.length}`);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+88
View File
@@ -0,0 +1,88 @@
import { PrismaClient } from '@prisma/client';
import { ensureWonLedger } from '../src/contract/won-ledger';
import { BIZ_TYPE } from '../src/bidding/bid-workflow';
const prisma = new PrismaClient();
async function main() {
const owner = await prisma.user.findFirst({
where: { tenantId: '1', status: 'ACTIVE', roles: { some: { role: { code: { in: ['owner', 'admin'] } } } } },
select: { id: true },
});
const won = await prisma.bidCase.findMany({
where: { tenantId: '1', status: 'WON' },
include: { party: true, legalEntity: true, expenses: { select: { amount: true } } },
});
let newContracts = 0;
let newProjects = 0;
let todos = 0;
for (const bid of won) {
const actorId = bid.createdById || owner?.id;
if (!actorId) throw new Error('没有可用的合同创建人');
const beforeC = await prisma.contract.count({
where: { bidCaseId: bid.id, status: { in: ['DRAFT', 'PENDING', 'APPROVED'] } },
});
const beforeP = await prisma.project.count({
where: { contract: { bidCaseId: bid.id } },
});
const result = await prisma.$transaction((tx) => ensureWonLedger(tx, bid, actorId));
if (
(await prisma.contract.count({
where: { bidCaseId: bid.id, status: { in: ['DRAFT', 'PENDING', 'APPROVED'] } },
})) > beforeC
) {
newContracts += 1;
}
if ((await prisma.project.count({ where: { contract: { bidCaseId: bid.id } } })) > beforeP) {
newProjects += 1;
}
await prisma.todoTask.updateMany({
where: {
status: 'OPEN',
bizType: BIZ_TYPE.CONVERT_CONTRACT,
bizId: bid.id,
},
data: { bizId: result.contract.id, title: `完善合同草稿:${bid.name}` },
});
const openTodo = await prisma.todoTask.findFirst({
where: {
status: 'OPEN',
bizType: BIZ_TYPE.CONVERT_CONTRACT,
bizId: result.contract.id,
},
});
if (!openTodo && result.contract.status === 'DRAFT') {
const assignees = await prisma.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
roles: { some: { role: { code: { in: ['biz_director', 'biz_staff', 'owner'] } } } },
},
});
if (assignees.length) {
await prisma.todoTask.createMany({
data: assignees.map((u) => ({
tenantId: '1',
title: `完善合同草稿:${bid.name}`,
bizType: BIZ_TYPE.CONVERT_CONTRACT,
bizId: result.contract.id,
assigneeId: u.id,
})),
});
todos += assignees.length;
}
}
}
console.log(
`won bids ${won.length}; new contracts ${newContracts}; new projects ${newProjects}; todos ${todos}`,
);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+65
View File
@@ -0,0 +1,65 @@
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
const prisma = new PrismaClient();
async function main() {
const username = '18115604840';
const password = '123456';
const role = await prisma.role.findFirst({ where: { code: 'admin' } });
if (!role) throw new Error('缺少 admin 角色,请先执行 prisma seed');
const root =
(await prisma.department.findFirst({ where: { code: 'ROOT' } })) ||
(await prisma.department.findFirst({ orderBy: { sortNo: 'asc' } }));
if (!root) throw new Error('缺少部门');
let emp = await prisma.employee.findFirst({ where: { mobile: username } });
if (!emp) {
emp = await prisma.employee.create({
data: {
name: '系统管理员',
employeeNo: 'E-ADMIN',
departmentId: root.id,
title: '管理员',
restSchedule: 'DOUBLE',
employmentStatus: 'REGULAR',
mobile: username,
hiredAt: new Date(),
},
});
} else if (!emp.mobile) {
emp = await prisma.employee.update({ where: { id: emp.id }, data: { mobile: username } });
}
const hash = await argon2.hash(password);
let user = await prisma.user.findFirst({ where: { username } });
if (!user) {
user = await prisma.user.create({
data: {
username,
passwordHash: hash,
displayName: emp.name,
employeeId: emp.id,
mustChangePassword: false,
status: 'ACTIVE',
},
});
} else {
user = await prisma.user.update({
where: { id: user.id },
data: { employeeId: emp.id, status: 'ACTIVE' },
});
}
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.id, roleId: role.id } },
create: { userId: user.id, roleId: role.id },
update: {},
});
console.log('prod admin ok', username);
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+141
View File
@@ -0,0 +1,141 @@
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'fs';
const prisma = new PrismaClient();
type OaExpense = {
name: string;
amount: number;
project: string;
date: string;
purpose: string;
remark: string | null;
expenseCategory: string;
};
type OaLoan = { name: string; amount: number; purpose: string; project: string };
const NAME_ALIAS: Record<string, string> = {
: '张婧妍',
: '张婧妍',
};
function normName(v?: string | null) {
const s = (v || '').trim();
return NAME_ALIAS[s] || s;
}
function amtEq(a: unknown, b: unknown) {
return Math.abs(Number(a) - Number(b)) < 0.021;
}
function sameProject(oa: string, locals: string[]) {
if (!oa) return locals.length === 0;
return locals.some((p) => p === oa || p.includes(oa) || oa.includes(p));
}
async function main() {
const oaExpenses = JSON.parse(readFileSync('/opt/apps/api/prisma/_oa_reimb_map.json', 'utf8')) as OaExpense[];
const oaLoans = JSON.parse(readFileSync('/opt/apps/api/prisma/_oa_loan_map.json', 'utf8')) as OaLoan[];
const expenses = await prisma.expenseClaim.findMany({
include: {
applicant: { select: { displayName: true, employee: { select: { name: true } } } },
project: { select: { name: true } },
bidCase: { select: { name: true } },
},
orderBy: { claimNo: 'asc' },
});
const used = new Set<number>();
let patchedExpenses = 0;
const unmatchedExpenses: string[] = [];
for (const row of expenses) {
const names = [normName(row.applicant?.displayName), normName(row.applicant?.employee?.name)].filter(Boolean);
const locals = [row.project?.name, row.bidCase?.name].filter((v): v is string => Boolean(v));
const date = String(row.submittedAt || '').slice(0, 10);
const idxs = oaExpenses
.map((o, i) => ({ o, i }))
.filter(({ o, i }) => !used.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name)));
const byProj = idxs.filter(({ o }) => sameProject(o.project, locals));
const byDate = idxs.filter(({ o }) => o.date === date);
const hit = (byProj.length === 1 ? byProj[0] : null) || (byDate.length === 1 ? byDate[0] : null) || (idxs.length === 1 ? idxs[0] : byProj[0] || byDate[0] || null);
if (!hit) {
unmatchedExpenses.push(`${row.claimNo} ${names[0] || '?'} ${row.amount}`);
continue;
}
used.add(hit.i);
await prisma.expenseClaim.update({
where: { id: row.id },
data: {
purpose: hit.o.purpose || null,
remark: hit.o.remark,
expenseCategory: hit.o.expenseCategory,
},
});
if (hit.o.purpose) {
await prisma.approvalTask.updateMany({
where: { bizId: row.id, bizType: 'EXPENSE_APPROVAL' },
data: { title: hit.o.purpose },
});
}
patchedExpenses += 1;
}
const loans = await prisma.loanRecord.findMany({
include: {
applicant: { select: { displayName: true, employee: { select: { name: true } } } },
},
orderBy: { loanNo: 'asc' },
});
const usedLoan = new Set<number>();
let patchedLoans = 0;
const unmatchedLoans: string[] = [];
for (const row of loans) {
const names = [normName(row.applicant?.displayName), normName(row.applicant?.employee?.name)].filter(Boolean);
const hit = oaLoans
.map((o, i) => ({ o, i }))
.find(({ o, i }) => !usedLoan.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name)) && (!row.purpose || row.purpose === o.purpose || o.purpose.includes(row.purpose) || row.purpose.includes(o.purpose)));
const fallback = oaLoans
.map((o, i) => ({ o, i }))
.find(({ o, i }) => !usedLoan.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name)));
const pick = hit || fallback;
if (!pick) {
unmatchedLoans.push(`${row.loanNo} ${names[0] || '?'}`);
await prisma.loanRecord.update({ where: { id: row.id }, data: { remark: null } });
if (row.purpose) {
await prisma.approvalTask.updateMany({
where: { bizId: row.id, bizType: 'LOAN_APPROVAL' },
data: { title: row.purpose },
});
}
continue;
}
usedLoan.add(pick.i);
await prisma.loanRecord.update({
where: { id: row.id },
data: { purpose: pick.o.purpose, remark: null },
});
await prisma.approvalTask.updateMany({
where: { bizId: row.id, bizType: 'LOAN_APPROVAL' },
data: { title: pick.o.purpose },
});
patchedLoans += 1;
}
console.log(
JSON.stringify(
{ patchedExpenses, unmatchedExpenses, patchedLoans, unmatchedLoans },
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+125
View File
@@ -0,0 +1,125 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_clean.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function entityKey(raw: unknown) {
const s = str(raw);
if (!s) return '';
if (s.includes('鲁兵')) return '山东鲁兵';
if (s.includes('隆创')) return '隆创';
if (s.includes('风影')) return '江苏风影随行科技有限公司';
return s;
}
const ENTITIES: { key: string; name: string; shortName: string }[] = [
{ key: '江苏风影随行科技有限公司', name: '江苏风影随行科技有限公司', shortName: '江苏风影' },
{ key: '山东鲁兵', name: '山东鲁兵', shortName: '山东鲁兵' },
{ key: '隆创', name: '隆创', shortName: '隆创' },
];
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const tenders = raw.oa_tender || [];
const entityIds = new Map<string, string>();
for (const e of ENTITIES) {
const existing = await prisma.legalEntity.findFirst({
where: { tenantId: '1', OR: [{ name: e.name }, { shortName: e.shortName }] },
});
if (existing) {
await prisma.legalEntity.update({ where: { id: existing.id }, data: { name: e.name, shortName: e.shortName } });
entityIds.set(e.key, existing.id);
} else {
const created = await prisma.legalEntity.create({
data: { name: e.name, shortName: e.shortName, isDefault: e.shortName === '江苏风影' },
});
entityIds.set(e.key, created.id);
}
}
await prisma.legalEntity.updateMany({
where: { name: { not: '江苏风影随行科技有限公司' } },
data: { isDefault: false },
});
await prisma.legalEntity.updateMany({
where: { name: '江苏风影随行科技有限公司' },
data: { isDefault: true },
});
const users = await prisma.user.findMany();
const userByName = new Map(users.map((u) => [u.displayName, u]));
const bids = await prisma.bidCase.findMany();
const usedNos = new Set<string>();
const matched: { rowId: string; t: Row }[] = [];
for (const t of tenders) {
const name = str(t.project_name);
const sys = str(t.system_number);
const official = str(t.project_number);
const row = bids.find((b) => b.name === name) || bids.find((b) => b.bidNo === sys || b.externalNo === official);
if (!row) continue;
matched.push({ rowId: row.id, t });
}
for (const m of matched) {
await prisma.bidCase.update({
where: { id: m.rowId },
data: { bidNo: `TMP-${m.rowId.replace(/-/g, '').slice(0, 20)}` },
});
}
for (const { rowId, t } of matched) {
const row = bids.find((b) => b.id === rowId)!;
const official = str(t.project_number);
const key = entityKey(t.participating_unit);
let bidNo = official || row.externalNo || row.bidNo;
if (usedNos.has(bidNo)) bidNo = `${bidNo}-${row.id.slice(0, 4)}`;
usedNos.add(bidNo);
const preparer = str(t.bid_prepare_person);
const creator = userByName.get(preparer === '张靖妍' ? '张婧妍' : preparer);
await prisma.bidCase.update({
where: { id: row.id },
data: {
bidNo,
externalNo: official || row.externalNo,
legalEntityId: key ? entityIds.get(key) || null : null,
openAt: when(t.bid_open_date) || row.openAt,
applyEndAt: when(t.file_apply_deadline) || row.applyEndAt,
applyStartAt: when(t.create_time) || row.applyStartAt,
createdById: creator?.id || row.createdById,
createdAt: when(t.create_time) || row.createdAt,
},
});
}
const patched = matched.length;
const loan = await prisma.loanRecord.findFirst({ where: { purpose: { contains: '重庆投标' } } });
const chongqing = await prisma.bidCase.findFirst({ where: { name: { contains: '门诊特病' } } });
if (loan && chongqing) {
await prisma.loanRecord.update({
where: { id: loan.id },
data: { bidCaseId: chongqing.id, costType: 'BID' },
});
}
console.log(JSON.stringify({ patchedBids: patched, entities: ENTITIES.map((e) => e.shortName), linkedChongqingLoan: Boolean(loan && chongqing) }));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+125
View File
@@ -0,0 +1,125 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function ensureUser(name: string, username: string, deptName: string, employeeNo: string, passwordHash: string) {
const existing = await prisma.user.findFirst({ where: { OR: [{ username }, { displayName: name }] } });
if (existing) return existing.id;
const dept =
(await prisma.department.findFirst({ where: { name: deptName } })) ||
(await prisma.department.findFirst({ where: { name: { contains: '研发' } } })) ||
(await prisma.department.findFirst({ where: { parentId: { not: null } } }));
if (!dept) throw new Error(`没有部门可挂 ${name}`);
const empRole = await prisma.role.findFirst({ where: { code: 'employee' } });
if (!empRole) throw new Error('没有 employee 角色');
let emp = await prisma.employee.findFirst({ where: { OR: [{ name }, { employeeNo }] } });
if (!emp) {
emp = await prisma.employee.create({
data: {
name,
employeeNo,
departmentId: dept.id,
title: '员工',
employmentStatus: 'LEFT',
remark: `OA 报销有此人(原部门 ${deptName}),人事花名册未导入,补建后挂单据,账号停用。`,
},
});
}
const user = await prisma.user.create({
data: {
username,
displayName: name,
passwordHash,
status: 'DISABLED',
employeeId: emp.id,
},
});
await prisma.userRole.create({ data: { userId: user.id, roleId: empRole.id } });
console.log('created leftover', name, user.id, 'dept', dept.name);
return user.id;
}
async function main() {
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('no admin');
await ensureUser('李乐菡', 'lilehan', '项目一部', 'E0115', admin.passwordHash);
const users = await prisma.user.findMany();
const byName = new Map(users.map((u) => [u.displayName, u.id]));
const ALIAS: Record<string, string> = { : '张婧妍' };
for (const [from, to] of Object.entries(ALIAS)) {
const uid = byName.get(to);
if (uid) byName.set(from, uid);
}
const expenses = await prisma.expenseClaim.findMany({ select: { id: true, remark: true, applicantId: true, status: true } });
let n = 0;
for (const e of expenses) {
const name = (e.remark || '').split(' / ')[0]?.trim();
if (!name || name === '系统管理员') continue;
const uid = byName.get(name);
if (!uid || uid === e.applicantId) continue;
await prisma.expenseClaim.update({ where: { id: e.id }, data: { applicantId: uid } });
n += 1;
}
const loans = await prisma.loanRecord.findMany({ select: { id: true, remark: true, applicantId: true } });
let ln = 0;
for (const e of loans) {
const name = (e.remark || '').split(' / ')[0]?.trim();
if (!name || name === '系统管理员') continue;
const uid = byName.get(name);
if (!uid || uid === e.applicantId) continue;
await prisma.loanRecord.update({ where: { id: e.id }, data: { applicantId: uid } });
ln += 1;
}
const approvers = await prisma.user.findMany({
where: { status: 'ACTIVE', roles: { some: { role: { code: { in: ['admin', 'finance', 'owner'] } } } } },
});
const pendingExp = await prisma.expenseClaim.findMany({ where: { status: 'PENDING' } });
const pendingLoan = await prisma.loanRecord.findMany({ where: { status: 'PENDING' } });
let tasks = 0;
for (const row of pendingExp) {
for (const u of approvers) {
const hit = await prisma.approvalTask.findFirst({
where: { bizType: 'EXPENSE_APPROVAL', bizId: row.id, assigneeId: u.id, status: 'PENDING' },
});
if (hit) continue;
await prisma.approvalTask.create({
data: {
title: `报销确认:${row.claimNo}`,
bizType: 'EXPENSE_APPROVAL',
bizId: row.id,
assigneeId: u.id,
},
});
tasks += 1;
}
}
for (const row of pendingLoan) {
for (const u of approvers) {
const hit = await prisma.approvalTask.findFirst({
where: { bizType: 'LOAN_APPROVAL', bizId: row.id, assigneeId: u.id, status: 'PENDING' },
});
if (hit) continue;
await prisma.approvalTask.create({
data: {
title: `借款确认:${row.loanNo}`,
bizType: 'LOAN_APPROVAL',
bizId: row.id,
assigneeId: u.id,
},
});
tasks += 1;
}
}
const left = await prisma.expenseClaim.count({ where: { applicantId: admin.id } });
console.log(JSON.stringify({ rematchedExpenses: n, rematchedLoans: ln, approvalTasks: tasks, adminStillHolds: left }));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+107
View File
@@ -0,0 +1,107 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { mapExpenseLineKind, parseOaJson } from './oa-json';
const prisma = new PrismaClient();
const FLOW = '/opt/import/prod/oa_flow_finance.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 200) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s;
}
async function main() {
const flow = parseOaJson<Record<string, Row[]>>(FLOW);
const itemsByReimb = new Map<string, Row[]>();
for (const item of flow.reimbursement_item || []) {
const key = str(item.reimbursement_id);
if (!key) continue;
const list = itemsByReimb.get(key) || [];
list.push(item);
itemsByReimb.set(key, list);
}
let claims = 0;
let lines = 0;
let skipped = 0;
for (const r of flow.reimbursement || []) {
const claimNo = str(r.reimburse_no);
if (!claimNo) continue;
const row = await prisma.expenseClaim.findFirst({ where: { tenantId: '1', claimNo } });
if (!row) {
skipped += 1;
continue;
}
const incoming = itemsByReimb.get(str(r.id)) || [];
await prisma.$transaction(async (tx) => {
await tx.expenseLine.deleteMany({ where: { claimId: row.id } });
for (const [i, line] of incoming.entries()) {
await tx.expenseLine.create({
data: {
claimId: row.id,
kind: mapExpenseLineKind(str(line.item_type), str(line.remark)),
amount: new Prisma.Decimal(num(line.amount)),
invoiceNo: clip(line.invoice_no, 80) || undefined,
occurAt: when(line.expense_date) || undefined,
remark: clip(line.remark || line.item_type, 200) || undefined,
sortNo: num(line.sort_order) || i,
},
});
lines += 1;
}
});
claims += 1;
}
const sample = await prisma.expenseClaim.findFirst({
where: { claimNo: 'BX-20260828-002' },
include: { lines: { orderBy: { sortNo: 'asc' } } },
});
const sum = sample?.lines.reduce((s, l) => s + Number(l.amount), 0);
console.log(
JSON.stringify(
{
claims,
lines,
skipped,
sample: sample
? {
claimNo: sample.claimNo,
header: Number(sample.amount),
lineCount: sample.lines.length,
lineSum: sum,
kinds: sample.lines.map((l) => `${l.kind}:${l.amount}:${l.remark}`),
}
: null,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+141
View File
@@ -0,0 +1,141 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_hr.json';
const ROLES: { code: string; name: string; dataScope: string }[] = [
{ code: 'rd_director', name: '研发总监', dataScope: 'DEPT' },
{ code: '3d_director', name: '三维总监', dataScope: 'DEPT' },
{ code: 'video_director', name: '摄制总监', dataScope: 'DEPT' },
{ code: 'material_director', name: '物资总监', dataScope: 'DEPT' },
{ code: 'pm', name: '项目经理', dataScope: 'DEPT' },
{ code: 'rd_staff', name: '研发技术岗', dataScope: 'SELF' },
{ code: '3d_staff', name: '三维技术岗', dataScope: 'SELF' },
{ code: 'video_staff', name: '摄制技术岗', dataScope: 'SELF' },
{ code: 'material_staff', name: '物资技术岗', dataScope: 'SELF' },
{ code: 'tech', name: '普通岗位', dataScope: 'SELF' },
];
const POST_ROLE: Record<string, string> = {
sysadmin: 'admin',
boss: 'owner',
business_director: 'biz_director',
business_staff: 'biz_staff',
rd_director: 'rd_director',
'3d_director': '3d_director',
video_director: 'video_director',
material_director: 'material_director',
pm: 'pm',
tech: 'tech',
rd_staff: 'rd_staff',
'3d_staff': '3d_staff',
video_staff: 'video_staff',
material_staff: 'material_staff',
};
function pickRole(userName: string, postCodes: string[]) {
if (userName === 'xibei') return 'employee';
if (userName === 'administrator' || userName === 'admin') return 'admin';
const rank: Record<string, number> = {
employee: 0,
tech: 1,
rd_staff: 1,
'3d_staff': 1,
video_staff: 1,
material_staff: 1,
pm: 2,
biz_staff: 3,
rd_director: 4,
'3d_director': 4,
video_director: 4,
material_director: 4,
tech_director: 4,
biz_director: 5,
owner: 6,
admin: 7,
};
let best: string | null = null;
let bestRank = -1;
for (const c of postCodes) {
const role = POST_ROLE[c];
if (!role) continue;
const r = rank[role] ?? 1;
if (r > bestRank) {
best = role;
bestRank = r;
}
}
return best || 'employee';
}
async function main() {
for (const r of ROLES) {
const existing = await prisma.role.findFirst({ where: { code: r.code } });
if (existing) {
await prisma.role.update({ where: { id: existing.id }, data: { name: r.name, dataScope: r.dataScope } });
} else {
await prisma.role.create({ data: { name: r.name, code: r.code, dataScope: r.dataScope } });
}
}
await prisma.role.updateMany({ where: { code: 'biz_staff' }, data: { name: '商务专员' } });
await prisma.role.updateMany({ where: { code: 'biz_director' }, data: { name: '商务总监' } });
await prisma.role.updateMany({ where: { code: 'owner' }, data: { name: '企业负责人' } });
const dump = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as {
posts: { id: number; code: string; name: string }[];
users: { id: number; userName: string; nickName: string }[];
userPosts: { postId: number; userId: number }[];
};
const postById = new Map(dump.posts.map((p) => [p.id, p]));
const postsByUser = new Map<number, { code: string; name: string }[]>();
for (const up of dump.userPosts) {
const post = postById.get(up.postId);
if (!post) continue;
const list = postsByUser.get(up.userId) || [];
list.push(post);
postsByUser.set(up.userId, list);
}
const roles = await prisma.role.findMany();
const roleByCode = new Map(roles.map((r) => [r.code, r.id]));
let n = 0;
for (const u of dump.users) {
if (u.userName === 'administrator') continue;
const user = await prisma.user.findFirst({ where: { username: u.userName } });
if (!user) continue;
const posts = postsByUser.get(u.id) || [];
const roleCode = pickRole(u.userName, posts.map((p) => p.code));
const roleId = roleByCode.get(roleCode);
if (!roleId) continue;
await prisma.userRole.deleteMany({ where: { userId: user.id } });
await prisma.userRole.create({ data: { userId: user.id, roleId } });
const title = posts.find((p) => POST_ROLE[p.code] === roleCode)?.name || posts[0]?.name;
if (title && user.employeeId) {
await prisma.employee.update({ where: { id: user.employeeId }, data: { title } });
}
n += 1;
}
const delQual = await prisma.qualification.deleteMany({ where: { code: 'QUAL_P6_OLD' } });
const cancelled = await prisma.approvalTask.updateMany({
where: { bizType: 'EXPENSE_APPROVAL', status: 'PENDING' },
data: { status: 'CANCELLED' },
});
console.log(
JSON.stringify({
reassignedUsers: n,
deletedTestQuals: delQual.count,
cancelledExpenseApprovals: cancelled.count,
}),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,40 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const admin = await prisma.role.findFirst({ where: { code: 'admin' } });
if (!admin) throw new Error('缺少 admin 角色');
await prisma.role.updateMany({ where: { code: 'rd_director' }, data: { dataScope: 'ALL' } });
const users = await prisma.user.findMany({
where: {
status: 'ACTIVE',
OR: [
{ displayName: { contains: '潘兴辉' } },
{ employee: { name: { contains: '潘兴辉' } } },
{ roles: { some: { role: { code: 'rd_director' } } } },
],
},
select: { id: true, displayName: true, username: true },
});
if (!users.length) throw new Error('没有找到潘兴辉或研发总监账号');
const result = await prisma.userRole.createMany({
data: users.map((u) => ({ userId: u.id, roleId: admin.id })),
skipDuplicates: true,
});
console.log(
`granted admin to: ${users.map((u) => u.displayName).join('、')} (new links ${result.count})`,
);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+41
View File
@@ -0,0 +1,41 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.findFirst({
where: {
status: 'ACTIVE',
OR: [{ displayName: { contains: '孙春庆' } }, { employee: { name: { contains: '孙春庆' } } }],
},
include: { roles: true },
});
if (!user) throw new Error('没有找到孙春庆');
const role =
(await prisma.role.findFirst({ where: { code: 'bid_screener' } })) ||
(await prisma.role.create({
data: { name: '项目筛选发起', code: 'bid_screener', dataScope: 'SELF' },
}));
const perms = await prisma.permission.findMany({
where: { code: { in: ['office', 'office:overview', 'bid', 'bid:screening'] } },
});
await prisma.rolePermission.createMany({
data: perms.map((p) => ({ roleId: role.id, permissionId: p.id })),
skipDuplicates: true,
});
await prisma.userRole.createMany({
data: [{ userId: user.id, roleId: role.id }],
skipDuplicates: true,
});
console.log(`granted bid screening to ${user.displayName} (${user.username})`);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+587
View File
@@ -0,0 +1,587 @@
// @ts-nocheck
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
const DUMP = path.resolve('/opt/import/prod/oa_hr.json');
const DEFAULT_PASSWORD = '123456';
const DEMO_USERS = [
'owner',
'bizdir',
'bizstaff',
'dirsoft',
'dir3d',
'dirfilm',
'dirmaterial',
'finance',
'hr',
'employee',
'softpm',
'batchdemo1',
];
const DEPT_META: Record<number, { deptType: string; categoryCode?: string }> = {
1: { deptType: 'ADMIN' },
2: { deptType: 'ADMIN' },
4: { deptType: 'BUSINESS' },
7: { deptType: 'ADMIN' },
8: { deptType: 'ADMIN' },
9: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' },
10: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' },
19: { deptType: 'TECH', categoryCode: 'CAT_FILM' },
23: { deptType: 'TECH', categoryCode: 'CAT_3D' },
210: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' },
211: { deptType: 'TECH', categoryCode: 'CAT_3D' },
212: { deptType: 'TECH', categoryCode: 'CAT_FILM' },
213: { deptType: 'TECH', categoryCode: 'CAT_MATERIAL' },
214: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' },
};
const OLD_DEPT_TO_OA: Record<string, string> = {
ROOT: 'OA_1',
BIZ: 'OA_4',
TECH_SOFTWARE: 'OA_9',
TECH_3D: 'OA_211',
TECH_FILM: 'OA_212',
TECH_MATERIAL: 'OA_213',
FIN: 'OA_2',
HR: 'OA_2',
};
const DEPT_MANAGER_OA: Record<number, number> = {
1: 101,
2: 101,
4: 107,
7: 101,
8: 101,
9: 131,
10: 131,
19: 102,
23: 105,
210: 131,
211: 105,
212: 102,
213: 104,
214: 131,
};
const POST_ROLE: Record<string, string> = {
sysadmin: 'admin',
boss: 'owner',
business_director: 'biz_director',
business_staff: 'biz_staff',
rd_director: 'rd_director',
'3d_director': '3d_director',
video_director: 'video_director',
material_director: 'material_director',
pm: 'pm',
tech: 'tech',
rd_staff: 'rd_staff',
'3d_staff': '3d_staff',
video_staff: 'video_staff',
material_staff: 'material_staff',
};
const ROLE_RANK: Record<string, number> = {
employee: 0,
tech: 1,
rd_staff: 1,
'3d_staff': 1,
video_staff: 1,
material_staff: 1,
pm: 2,
biz_staff: 3,
tech_director: 4,
rd_director: 4,
'3d_director': 4,
video_director: 4,
material_director: 4,
biz_director: 5,
owner: 6,
admin: 7,
};
type Dump = {
depts: { id: number; name: string; parentId: number; orderNum: number; delFlag: string }[];
posts: { id: number; code: string; name: string }[];
users: {
id: number;
sex: string;
email: string;
phone: string;
deptId: number;
remark: string | null;
status: string;
nickName: string;
userName: string;
entryDate: string | null;
leaveDate: string | null;
employmentStatus: string;
}[];
userPosts: { postId: number; userId: number }[];
};
function empNo(oaId: number) {
return `E${String(oaId).padStart(4, '0')}`;
}
function gender(sex: string) {
if (sex === '0') return 'MALE';
if (sex === '1') return 'FEMALE';
return null;
}
function empStatus(code: string) {
if (code === '1') return 'INTERN';
if (code === '2') return 'PROBATION';
if (code === '4') return 'LEFT';
return 'REGULAR';
}
function when(v: string | null) {
if (!v || v.startsWith('0000')) return null;
const d = new Date(v.includes('T') ? v : `${v}T00:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
function addMonths(d: Date, n: number) {
const x = new Date(d);
x.setMonth(x.getMonth() + n);
return x;
}
function pickRole(userName: string, postCodes: string[]) {
if (userName === 'xibei') return 'employee';
let best: string | null = null;
let bestRank = -1;
for (const c of postCodes) {
const role = POST_ROLE[c];
if (!role) continue;
const r = ROLE_RANK[role] ?? 1;
if (r > bestRank) {
best = role;
bestRank = r;
}
}
return best || 'employee';
}
async function remumber<T extends { id: string; createdAt: Date }>(
rows: T[],
prefix: string,
update: (id: string, no: string) => Promise<unknown>,
) {
for (const r of rows) await update(r.id, `TMP-${r.id.replace(/-/g, '').slice(0, 20)}`);
const groups = new Map<number, T[]>();
const sorted = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
for (const r of sorted) {
const y = r.createdAt.getFullYear();
const list = groups.get(y) || [];
list.push(r);
groups.set(y, list);
}
for (const [year, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
let i = 1;
for (const r of list) {
await update(r.id, `${prefix}-${year}-${String(i).padStart(5, '0')}`);
i += 1;
}
}
}
async function remapDeptFks(codeToId: Map<string, string>) {
const old = await prisma.department.findMany({ where: { tenantId: '1' } });
const oldByCode = new Map(old.map((d) => [d.code, d.id]));
const mapping = new Map<string, string>();
for (const d of old) {
const targetCode = OLD_DEPT_TO_OA[d.code] || (d.code.startsWith('OA_') ? d.code : 'OA_2');
const nid = codeToId.get(targetCode);
if (nid && nid !== d.id) mapping.set(d.id, nid);
}
for (const [from, to] of mapping) {
await prisma.projectTask.updateMany({ where: { departmentId: from }, data: { departmentId: to } });
await prisma.employmentEvent.updateMany({ where: { fromDeptId: from }, data: { fromDeptId: to } });
await prisma.employmentEvent.updateMany({ where: { toDeptId: from }, data: { toDeptId: to } });
}
return { oldByCode, mapping };
}
async function reassignDemoUsers(demoIds: string[], adminId: string) {
if (!demoIds.length) return;
await prisma.bidReviewer.deleteMany({ where: { userId: { in: demoIds } } });
await prisma.bidAssignee.deleteMany({ where: { userId: { in: demoIds } } });
await prisma.contractReviewer.deleteMany({ where: { userId: { in: demoIds } } });
await prisma.todoTask.deleteMany({ where: { assigneeId: { in: demoIds } } });
await prisma.approvalTask.deleteMany({ where: { assigneeId: { in: demoIds } } });
await prisma.workReport.deleteMany({ where: { OR: [{ authorId: { in: demoIds } }, { toUserId: { in: demoIds } }, { ccUserId: { in: demoIds } }] } });
await prisma.refreshToken.deleteMany({ where: { userId: { in: demoIds } } });
await prisma.userRole.deleteMany({ where: { userId: { in: demoIds } } });
const patch = async (model: string, field: string) => {
const delegate = (prisma as unknown as Record<string, { updateMany: (a: unknown) => Promise<unknown> }>)[model];
if (!delegate?.updateMany) return;
try {
await delegate.updateMany({
where: { [field]: { in: demoIds } },
data: { [field]: adminId },
});
} catch (e) {
console.warn(`skip ${model}.${field}`, (e as Error).message);
}
};
await patch('bidCase', 'createdById');
await patch('bidActionLog', 'actorId');
await patch('bidDocVersion', 'submittedById');
await patch('contract', 'createdById');
await patch('contractActionLog', 'actorId');
await patch('expenseClaim', 'applicantId');
await patch('expenseClaim', 'decidedById');
await patch('loanRecord', 'applicantId');
await patch('loanRecord', 'decidedById');
await patch('sealRequest', 'applicantId');
await patch('sealRequest', 'decidedById');
await patch('timesheet', 'userId');
await patch('projectTask', 'assigneeId');
await patch('projectAcceptance', 'actorId');
await patch('employmentEvent', 'actorId');
await patch('attendanceMonth', 'importedById');
await patch('payrollRun', 'createdById');
await patch('purchaseRequest', 'applicantId');
await patch('purchaseRequest', 'decidedById');
await patch('paymentRecord', 'createdById');
await patch('projectChange', 'actorId');
await patch('assetOccupancy', 'holderId');
await patch('invoiceRecord', 'createdById');
await patch('operationLog', 'userId');
await patch('credentialBorrow', 'borrowerId');
await prisma.user.deleteMany({ where: { id: { in: demoIds } } });
}
async function main() {
const dump = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Dump;
const hash = await argon2.hash(DEFAULT_PASSWORD);
const posts = new Map(dump.posts.map((p) => [p.id, p]));
const postsByUser = new Map<number, string[]>();
const postNamesByUser = new Map<number, string[]>();
for (const up of dump.userPosts) {
const p = posts.get(up.postId);
if (!p) continue;
postsByUser.set(up.userId, [...(postsByUser.get(up.userId) || []), p.code]);
postNamesByUser.set(up.userId, [...(postNamesByUser.get(up.userId) || []), p.name]);
}
await prisma.role.updateMany({ where: { code: { in: ['admin', 'owner', 'finance', 'hr'] } }, data: { dataScope: 'ALL' } });
await prisma.role.updateMany({ where: { code: { in: ['biz_director', 'tech_director'] } }, data: { dataScope: 'DEPT' } });
await prisma.role.updateMany({ where: { code: { in: ['biz_staff', 'employee'] } }, data: { dataScope: 'SELF' } });
const numberRules = [
{ code: 'BID', name: '投标编号', prefix: 'BID', padLength: 5 },
{ code: 'HT', name: '合同编号', prefix: 'HT', padLength: 5 },
{ code: 'PRJ', name: '项目编号', prefix: 'PRJ', padLength: 5 },
{ code: 'BX', name: '报销编号', prefix: 'BX', padLength: 5 },
{ code: 'JK', name: '借款编号', prefix: 'JK', padLength: 5 },
{ code: 'BZ', name: '保证金编号', prefix: 'BZ', padLength: 5 },
];
for (const r of numberRules) {
await prisma.numberRule.upsert({
where: { tenantId_code: { tenantId: '1', code: r.code } },
update: { prefix: r.prefix, padLength: r.padLength, name: r.name },
create: { ...r, tenantId: '1' },
});
}
const roles = await prisma.role.findMany();
const roleByCode = new Map(roles.map((r) => [r.code, r.id]));
const codeToId = new Map<string, string>();
const sortedDepts = [...dump.depts].sort((a, b) => a.parentId - b.parentId || a.orderNum - b.orderNum);
for (const d of sortedDepts) {
const code = `OA_${d.id}`;
const meta = DEPT_META[d.id] || { deptType: 'ADMIN' };
const parentCode = d.parentId ? `OA_${d.parentId}` : null;
const parentId = parentCode ? codeToId.get(parentCode) || null : null;
const existing = await prisma.department.findFirst({ where: { tenantId: '1', code } });
const row = existing
? await prisma.department.update({
where: { id: existing.id },
data: {
name: d.name,
deptType: meta.deptType,
categoryCode: meta.categoryCode || null,
sortNo: d.orderNum,
parentId,
},
})
: await prisma.department.create({
data: {
name: d.name,
code,
deptType: meta.deptType,
categoryCode: meta.categoryCode || null,
sortNo: d.orderNum,
parentId,
},
});
codeToId.set(code, row.id);
}
await remapDeptFks(codeToId);
const officeId = codeToId.get('OA_2')!;
const empByOa = new Map<number, string>();
const empByName = new Map<string, string>();
const adminUser = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!adminUser) throw new Error('本地必须先有 admin 账号');
const adminEmpId =
adminUser.employeeId ||
(
await prisma.employee.create({
data: {
name: '系统管理员',
employeeNo: 'E0000',
departmentId: officeId,
title: '系统管理员',
employmentStatus: 'REGULAR',
},
})
).id;
const adminOa = dump.users.find((u) => u.userName === 'administrator');
await prisma.employee.update({
where: { id: adminEmpId },
data: {
name: adminOa?.nickName || '系统管理员',
employeeNo: 'E0000',
departmentId: officeId,
title: '系统管理员',
employmentStatus: 'REGULAR',
hiredAt: when(adminOa?.entryDate || '2026-06-04'),
gender: gender(adminOa?.sex || '2'),
email: adminOa?.email || null,
mobile: adminOa?.phone || null,
remark: adminOa?.remark || '超级管理员',
managerId: null,
},
});
empByOa.set(1, adminEmpId);
empByName.set('系统管理员', adminEmpId);
for (const u of dump.users) {
if (u.userName === 'administrator') continue;
const deptId = codeToId.get(`OA_${u.deptId}`) || officeId;
const status = empStatus(u.employmentStatus);
const hiredAt = when(u.entryDate);
const titles = postNamesByUser.get(u.id) || [];
const title = titles[0] || u.remark || '员工';
const no = empNo(u.id);
const data = {
name: u.nickName,
departmentId: deptId,
title,
restSchedule: DEPT_META[u.deptId]?.categoryCode === 'CAT_SOFTWARE' ? 'DOUBLE' : 'SINGLE',
employmentStatus: status,
hiredAt,
internStartAt: status === 'INTERN' ? hiredAt : null,
probationEndAt: status === 'PROBATION' && hiredAt ? addMonths(hiredAt, 3) : null,
leftAt: when(u.leaveDate),
gender: gender(u.sex),
email: u.email || null,
mobile: u.phone || null,
remark: u.remark,
};
const existing = await prisma.employee.findFirst({ where: { tenantId: '1', employeeNo: no } });
const row = existing
? await prisma.employee.update({ where: { id: existing.id }, data })
: await prisma.employee.create({ data: { ...data, employeeNo: no } });
empByOa.set(u.id, row.id);
empByName.set(u.nickName, row.id);
}
for (const u of dump.users) {
if (u.userName === 'administrator') continue;
const mgrOa = DEPT_MANAGER_OA[u.deptId];
if (!mgrOa || mgrOa === u.id) continue;
const managerId = empByOa.get(mgrOa);
const id = empByOa.get(u.id);
if (managerId && id) await prisma.employee.update({ where: { id }, data: { managerId } });
}
await prisma.user.update({
where: { id: adminUser.id },
data: {
passwordHash: hash,
displayName: '系统管理员',
employeeId: adminEmpId,
status: 'ACTIVE',
},
});
for (const u of dump.users) {
if (u.userName === 'administrator') continue;
const employeeId = empByOa.get(u.id)!;
const roleCode = pickRole(u.userName, postsByUser.get(u.id) || []);
const roleId = roleByCode.get(roleCode);
if (!roleId) throw new Error(`缺少角色 ${roleCode}`);
const disabled = u.status === '1' || u.employmentStatus === '4';
const existing = await prisma.user.findFirst({ where: { tenantId: '1', username: u.userName } });
const row = existing
? await prisma.user.update({
where: { id: existing.id },
data: {
passwordHash: hash,
displayName: u.nickName,
employeeId,
status: disabled ? 'DISABLED' : 'ACTIVE',
},
})
: await prisma.user.create({
data: {
username: u.userName,
passwordHash: hash,
displayName: u.nickName,
employeeId,
status: disabled ? 'DISABLED' : 'ACTIVE',
},
});
await prisma.userRole.deleteMany({ where: { userId: row.id } });
await prisma.userRole.create({ data: { userId: row.id, roleId } });
}
const users = await prisma.user.findMany({ include: { employee: true } });
const userByName = new Map(users.map((u) => [u.displayName, u.id]));
userByName.set('系统管理员', adminUser.id);
const rematchByText = async (
model: 'expenseClaim' | 'loanRecord' | 'sealRequest',
field: 'remark' | 'reason',
) => {
const rows = await prisma[model].findMany({ select: { id: true, [field]: true } as never });
let n = 0;
for (const r of rows as { id: string; remark?: string; reason?: string }[]) {
const name = ((r.remark || r.reason || '') as string).split(' / ')[0]?.trim();
if (!name) continue;
const uid = userByName.get(name);
if (!uid) continue;
await prisma[model].update({ where: { id: r.id }, data: { applicantId: uid } });
n += 1;
}
console.log(`matched ${model}`, n);
};
await rematchByText('expenseClaim', 'remark');
await rematchByText('loanRecord', 'remark');
await rematchByText('sealRequest', 'reason');
const keepUsernames = new Set(['admin', ...dump.users.filter((u) => u.userName !== 'administrator').map((u) => u.userName)]);
const demo = await prisma.user.findMany({
where: { OR: [{ username: { in: DEMO_USERS } }, { username: { notIn: [...keepUsernames] } }] },
});
const demoIds = demo.filter((u) => u.username !== 'admin').map((u) => u.id);
console.log(
'delete demo users',
demo.filter((u) => u.username !== 'admin').map((u) => u.username),
);
await reassignDemoUsers(demoIds, adminUser.id);
const keepEmp = new Set([adminEmpId, ...empByOa.values()]);
const leftoverEmp = await prisma.employee.findMany({ where: { id: { notIn: [...keepEmp] } } });
const leftoverIds = leftoverEmp.map((e) => e.id);
if (leftoverIds.length) {
await prisma.payrollItem.deleteMany({ where: { employeeId: { in: leftoverIds } } });
await prisma.attendanceMonth.deleteMany({ where: { employeeId: { in: leftoverIds } } });
await prisma.employmentEvent.deleteMany({ where: { employeeId: { in: leftoverIds } } });
await prisma.user.updateMany({ where: { employeeId: { in: leftoverIds } }, data: { employeeId: null } });
await prisma.employee.updateMany({ where: { managerId: { in: leftoverIds } }, data: { managerId: null } });
await prisma.employee.deleteMany({ where: { id: { in: leftoverIds } } });
}
console.log(
'deleted demo employees',
leftoverEmp.map((e) => e.name),
);
const keepDept = new Set(codeToId.values());
const leftoverDept = await prisma.department.findMany({ where: { id: { notIn: [...keepDept] } } });
if (leftoverDept.length) {
const leftoverDeptIds = leftoverDept.map((d) => d.id);
await prisma.employee.updateMany({
where: { departmentId: { in: leftoverDeptIds } },
data: { departmentId: officeId },
});
await prisma.projectTask.updateMany({
where: { departmentId: { in: leftoverDeptIds } },
data: { departmentId: officeId },
});
await prisma.department.updateMany({ where: { id: { in: leftoverDeptIds } }, data: { parentId: null } });
await prisma.department.deleteMany({ where: { id: { in: leftoverDeptIds } } });
}
console.log(
'deleted demo depts',
leftoverDept.map((d) => `${d.code}:${d.name}`),
);
const demoBid = await prisma.bidCase.findMany({ where: { name: '某市展厅数字化升级' } });
for (const b of demoBid) {
const used =
(await prisma.expenseClaim.count({ where: { bidCaseId: b.id } })) +
(await prisma.contract.count({ where: { bidCaseId: b.id } }));
if (used) continue;
await prisma.bidReviewer.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bidAssignee.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bidActionLog.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bidDocVersion.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bondRecord.deleteMany({ where: { bidCaseId: b.id } });
await prisma.sealRequest.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bidCase.delete({ where: { id: b.id } });
console.log('deleted demo bid', b.bidNo);
}
await remumber(
await prisma.bidCase.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'BID',
(id, bidNo) => prisma.bidCase.update({ where: { id }, data: { bidNo } }),
);
await remumber(
await prisma.contract.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'HT',
(id, contractNo) => prisma.contract.update({ where: { id }, data: { contractNo } }),
);
await remumber(
await prisma.project.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'PRJ',
(id, projectNo) => prisma.project.update({ where: { id }, data: { projectNo } }),
);
await remumber(
await prisma.expenseClaim.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'BX',
(id, claimNo) => prisma.expenseClaim.update({ where: { id }, data: { claimNo } }),
);
await remumber(
await prisma.loanRecord.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'JK',
(id, loanNo) => prisma.loanRecord.update({ where: { id }, data: { loanNo } }),
);
await remumber(
await prisma.bondRecord.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'BZ',
(id, bondNo) => prisma.bondRecord.update({ where: { id }, data: { bondNo } }),
);
const empCount = await prisma.employee.count();
const userCount = await prisma.user.count();
const deptCount = await prisma.department.count();
const sample = await prisma.expenseClaim.findMany({ take: 3, include: { applicant: true }, orderBy: { createdAt: 'desc' } });
console.log({ empCount, userCount, deptCount, sampleNos: sample.map((s) => `${s.claimNo}:${s.applicant.displayName}`) });
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+306
View File
@@ -0,0 +1,306 @@
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { createGunzip } from 'zlib';
import { basename, join } from 'path';
import { createHash, randomUUID } from 'crypto';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const DUMP = process.env.OA_DUMP || '/opt/backup/cloud-20260830/oa.sql.gz';
const UPLOAD_ROOT = process.env.OA_UPLOAD || '/opt/backup/cloud-20260830/oa-uploadPath';
const SKIP_TITLE = /已更新至|发现新版本|推荐更新|桌面端|移动端|^App /;
function stableId(key: string) {
const hex = createHash('sha1').update(key).digest('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
function parseMysqlTuples(valuesSql: string): unknown[][] {
const rows: unknown[][] = [];
let i = 0;
const s = valuesSql;
while (i < s.length) {
while (i < s.length && ' \n\r\t,'.includes(s[i])) i += 1;
if (i >= s.length) break;
if (s[i] !== '(') throw new Error(`expected ( at ${i}`);
i += 1;
const row: unknown[] = [];
while (true) {
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s.startsWith('_binary ', i)) i += 8;
if (s.startsWith('NULL', i) && (i + 4 === s.length || ',) \n\r\t'.includes(s[i + 4]))) {
row.push(null);
i += 4;
} else if (s[i] === "'" || s[i] === '"') {
const q = s[i];
i += 1;
let out = '';
while (i < s.length) {
const ch = s[i];
if (ch === '\\') {
const n = s[i + 1];
out += n === 'n' ? '\n' : n === 'r' ? '\r' : n === 't' ? '\t' : n === '0' ? '\0' : n;
i += 2;
continue;
}
if (ch === q) {
if (s[i + 1] === q) {
out += q;
i += 2;
continue;
}
i += 1;
break;
}
out += ch;
i += 1;
}
row.push(out);
} else {
const start = i;
while (i < s.length && s[i] !== ',' && s[i] !== ')') i += 1;
const raw = s.slice(start, i).trim();
row.push(raw === '' ? null : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw);
}
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s[i] === ',') {
i += 1;
continue;
}
if (s[i] === ')') {
i += 1;
rows.push(row);
break;
}
throw new Error(`bad token at ${i}: ${s.slice(i, i + 20)}`);
}
}
return rows;
}
async function readDump(): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
createReadStream(DUMP)
.pipe(createGunzip())
.on('data', (c: Buffer) => chunks.push(c))
.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
.on('error', reject);
});
}
function insertsFor(dump: string, table: string) {
const rows: unknown[][] = [];
const needle = `INSERT INTO \`${table}\` VALUES `;
let from = 0;
while (true) {
const start = dump.indexOf(needle, from);
if (start < 0) break;
let i = start + needle.length;
const s = dump;
while (i < s.length) {
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s[i] === ';') {
i += 1;
break;
}
if (s[i] === ',') {
i += 1;
continue;
}
if (s[i] !== '(') break;
const sliceStart = i;
let depth = 0;
let inStr = false;
let q = '';
while (i < s.length) {
const ch = s[i];
if (inStr) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === q) {
inStr = false;
}
i += 1;
continue;
}
if (ch === "'" || ch === '"') {
inStr = true;
q = ch;
i += 1;
continue;
}
if (ch === '(') depth += 1;
if (ch === ')') {
depth -= 1;
if (depth === 0) {
i += 1;
break;
}
}
i += 1;
}
rows.push(...parseMysqlTuples(s.slice(sliceStart, i)));
}
from = i;
}
return rows;
}
function mimeOf(name: string) {
const lower = name.toLowerCase();
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (lower.endsWith('.doc')) return 'application/msword';
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
return 'application/octet-stream';
}
function resolveUpload(rel: string) {
const cleaned = rel.replace(/^\/profile\//, '').replace(/^\//, '');
const candidates = [join(UPLOAD_ROOT, cleaned), join(UPLOAD_ROOT, 'upload', cleaned.replace(/^upload\//, ''))];
return candidates.find((p) => existsSync(p));
}
async function attachFile(noticeId: string, rel: string, authorId: string) {
if (!rel?.trim()) return;
const src = resolveUpload(rel);
if (!src) {
console.warn('missing attachment', rel);
return;
}
const fileName = basename(src);
const buf = readFileSync(src);
const id = stableId(`oa-file:${rel}`);
const ext = (fileName.split('.').pop() || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
const storageKey = `NOTICE/${noticeId}/${id}.${ext || 'bin'}`;
const full = join(process.cwd(), 'uploads', storageKey);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, buf);
await prisma.fileAsset.upsert({
where: { id },
update: { fileName, size: buf.length, mimeType: mimeOf(fileName), storageKey, bizId: noticeId },
create: {
id,
tenantId: '1',
bizType: 'NOTICE',
bizId: noticeId,
fileName,
mimeType: mimeOf(fileName),
size: buf.length,
storageKey,
uploadedById: authorId,
},
});
}
async function upsertNotice(opts: {
key: string;
title: string;
content: string;
kind: 'ANNOUNCEMENT' | 'POLICY';
createdAt?: Date | null;
authorId: string;
attachment?: string | null;
}) {
const title = opts.title.trim();
if (!title) return;
const id = stableId(opts.key);
const content = opts.content || '';
await prisma.notice.upsert({
where: { id },
update: { title, content, kind: opts.kind, status: 'PUBLISHED' },
create: {
id,
tenantId: '1',
title,
content,
kind: opts.kind,
status: 'PUBLISHED',
createdById: opts.authorId,
createdAt: opts.createdAt || undefined,
},
});
if (opts.attachment) await attachFile(id, opts.attachment, opts.authorId);
}
function asDate(v: unknown) {
if (!v) return null;
const d = new Date(String(v));
return Number.isNaN(d.getTime()) ? null : d;
}
async function main() {
const admin = await prisma.user.findFirst({ where: { username: '18115604840' } });
if (!admin) throw new Error('缺少管理员账号');
await prisma.notice.deleteMany({ where: { title: '未命名', status: 'DRAFT' } });
const dump = await readDump();
let n = 0;
for (const row of insertsFor(dump, 'oa_announcement')) {
const title = String(row[1] || '');
const status = String(row[4] || '');
if (status && status !== '正常') continue;
await upsertNotice({
key: `oa-announcement:${row[0]}`,
title,
content: String(row[15] || ''),
kind: 'ANNOUNCEMENT',
createdAt: asDate(row[17]) || asDate(row[11]),
authorId: admin.id,
attachment: row[14] ? String(row[14]) : null,
});
n += 1;
}
for (const row of insertsFor(dump, 'oa_regulation')) {
const title = String(row[1] || '');
const status = String(row[16] || '');
if (status && status !== '正常') continue;
await upsertNotice({
key: `oa-regulation:${row[0]}`,
title,
content: String(row[19] || ''),
kind: 'POLICY',
createdAt: asDate(row[15]) || asDate(row[21]),
authorId: admin.id,
attachment: row[18] ? String(row[18]) : null,
});
n += 1;
}
for (const row of insertsFor(dump, 'sys_notice')) {
const type = String(row[2] || '');
const title = String(row[1] || '');
const status = String(row[4] || '0');
if (type !== '2') continue;
if (status === '1') continue;
if (SKIP_TITLE.test(title)) continue;
await upsertNotice({
key: `sys-notice:${row[0]}`,
title,
content: String(row[3] || ''),
kind: 'ANNOUNCEMENT',
createdAt: asDate(row[7]),
authorId: admin.id,
});
n += 1;
}
const total = await prisma.notice.count();
const files = await prisma.fileAsset.count({ where: { bizType: 'NOTICE' } });
console.log(`imported ${n} source rows; notices=${total} files=${files}`);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+371
View File
@@ -0,0 +1,371 @@
// @ts-nocheck
import { PrismaClient, Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_core.json';
const HR = '/opt/import/prod/oa_hr.json';
const OA_FILES = '/opt/import/prod/oa-files';
const UPLOAD = path.join(process.cwd(), 'uploads');
type Row = Record<string, unknown>;
const MIME: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
zip: 'application/zip',
rar: 'application/x-rar-compressed',
};
const NAME_ALIAS: Record<string, string> = {
: '张婧妍',
};
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 2000) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s || undefined;
}
function mapProjectType(v: unknown, projectName?: unknown) {
const name = str(projectName);
if (name.includes('三维')) return '三维';
if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制';
const s = str(v);
if (s.includes('三维')) return '三维';
if (s.includes('摄制') || s.includes('视频')) return '摄制';
if (s.includes('物资')) return '物资';
if (s.includes('集成')) return '集成';
return '软件';
}
function mapQual(raw: unknown) {
const s = str(raw);
if (!s) return '无需资质';
try {
const arr = JSON.parse(s) as { item?: string }[];
const item = str(arr?.[0]?.item);
if (!item || item === '无') return '无需资质';
if (item.includes('军')) return '军二';
if (item.includes('涉')) return '涉乙';
return item.slice(0, 20);
} catch {
if (s.includes('军')) return '军二';
if (s.includes('涉')) return '涉乙';
return '其他';
}
}
function profilePaths(v: unknown) {
const out: string[] = [];
if (!v) return out;
for (const chunk of str(v).split(/[,;]/)) {
const piece = chunk.trim();
const first = piece.split('|')[0].trim();
const idx = first.indexOf('/profile/upload/');
const p = idx >= 0 ? first.slice(idx) : first;
if (p.startsWith('/profile/upload/')) out.push(p);
}
return out;
}
function relOf(p: string) {
return p.replace(/^\/profile\/upload\//, '');
}
function displayName(p: string) {
const pipe = str(p).split('|')[1];
if (pipe && !pipe.includes('/')) return path.basename(pipe);
return path.basename(relOf(p.split('|')[0]));
}
function mimeOf(name: string) {
const ext = name.split('.').pop()?.toLowerCase() || 'bin';
return MIME[ext] || 'application/octet-stream';
}
function prettyName(name: string) {
try {
return decodeURIComponent(name);
} catch {
return name;
}
}
async function attach(
bizType: string,
bizId: string,
urls: unknown,
uploadedById: string,
seen: Set<string>,
) {
let n = 0;
let firstNas: string | undefined;
for (const raw of profilePaths(urls)) {
const rel = relOf(raw);
const src = path.join(OA_FILES, rel);
if (!fs.existsSync(src)) continue;
const buf = fs.readFileSync(src);
const hash = createHash('sha1').update(buf).digest('hex').slice(0, 16);
const key = `${bizId}:${hash}`;
if (seen.has(key)) continue;
seen.add(key);
const id = randomUUID();
const ext = (path.extname(rel).replace('.', '') || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
const storageKey = `OA/${bizType}/${bizId}/${id}.${ext || 'bin'}`;
const dest = path.join(UPLOAD, storageKey);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
const name = prettyName(displayName(raw) || path.basename(rel));
await prisma.fileAsset.create({
data: {
id,
tenantId: '1',
bizType,
bizId,
fileName: name.slice(0, 180),
mimeType: mimeOf(name),
size: buf.length,
storageKey,
uploadedById,
},
});
n += 1;
firstNas ||= raw;
}
return { n, firstNas };
}
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const hr = JSON.parse(fs.readFileSync(HR, 'utf8')) as { users: { id: number; userName: string; nickName: string }[] };
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('没有 admin');
fs.mkdirSync(UPLOAD, { recursive: true });
const users = await prisma.user.findMany();
const byUsername = new Map(users.map((u) => [u.username, u]));
const byName = new Map(users.map((u) => [u.displayName, u]));
for (const [a, b] of Object.entries(NAME_ALIAS)) {
const u = byName.get(b);
if (u) byName.set(a, u);
}
const oaUser = new Map(hr.users.map((u) => [String(u.id), u.userName]));
const userFromOaId = (id: unknown) => {
const uname = oaUser.get(str(id));
return uname ? byUsername.get(uname) : undefined;
};
const userFromName = (name: unknown) => {
const n = NAME_ALIAS[str(name)] || str(name);
return byName.get(n);
};
await prisma.fileAsset.deleteMany({ where: { storageKey: { startsWith: 'OA/' } } });
const seen = new Set<string>();
const stats = { bids: 0, bidFiles: 0, contracts: 0, contractFiles: 0, expenses: 0, expenseFiles: 0, projects: 0, purchases: 0 };
const bids = await prisma.bidCase.findMany();
const bidByName = new Map(bids.map((b) => [b.name, b]));
const tenders = (raw.oa_tender || []).filter((t) => /^TB-/.test(str(t.system_number)) && str(t.project_name));
for (const t of tenders) {
const local = bidByName.get(str(t.project_name));
if (!local) {
console.warn('no local bid', t.project_name);
continue;
}
const creator = userFromOaId(t.create_by) || userFromName(t.bid_prepare_person) || admin;
const drafter = userFromName(t.bid_prepare_person);
const bidder = userFromOaId(t.bid_delivery_person_id) || userFromName(t.bid_delivery_person);
const announce = await attach('BID_ANNOUNCE', local.id, t.announcement_file, creator.id, seen);
const extra = await attach('BID_FILE', local.id, t.attachment, creator.id, seen);
const formal = await attach('BID_FORMAL', local.id, t.formal_bid_file, creator.id, seen);
const proof = await attach('BID_PROOF', local.id, t.submit_proof_file, creator.id, seen);
const nas = announce.firstNas || extra.firstNas || formal.firstNas || proof.firstNas;
await prisma.bidCase.update({
where: { id: local.id },
data: {
externalNo: clip(t.project_number, 120) || local.externalNo,
projectType: mapProjectType(t.project_type, t.project_name),
source: clip(t.project_source, 50) || local.source,
qualificationNeed: mapQual(t.bid_doc_requirement),
tenderMethod: clip(t.tender_method, 50) || local.tenderMethod,
applyStartAt: when(t.create_time) || local.applyStartAt,
applyEndAt: when(t.file_apply_deadline) || local.applyEndAt,
applyMethod: announce.firstNas || extra.firstNas ? '网上申领' : '其他',
openAt: when(t.bid_open_date) || local.openAt,
openMethod: str(t.tender_address) ? '线下开标' : '其他',
openPlace: clip(t.tender_address, 200) || local.openPlace,
projectNature: clip(t.project_nature, 50) || local.projectNature,
securityLevel: str(t.security_level) || local.securityLevel,
contactName: clip(t.bid_contact_person, 64) || local.contactName,
contactPhone: clip(t.bid_contact_phone, 32) || local.contactPhone,
bondMethod: clip(t.deposit_pay_method, 20) || local.bondMethod,
bondStatus: clip(t.deposit_status, 20) || local.bondStatus,
bondPaidAt: when(t.deposit_pay_date) || local.bondPaidAt,
delivererName: clip(t.bid_delivery_person, 64) || local.delivererName,
priceCap: t.price_limit == null || t.price_limit === '' ? local.priceCap : String(t.price_limit),
quoteAmount: t.bid_quotation == null || t.bid_quotation === '' ? local.quoteAmount : String(t.bid_quotation),
summary: clip(t.project_content || t.project_intro, 4000) || local.summary,
nasPath: nas || local.nasPath,
remark: clip(t.remark, 1000) || local.remark,
createdById: creator.id,
},
});
if (drafter) {
await prisma.bidAssignee.upsert({
where: { bidCaseId_userId_role: { bidCaseId: local.id, userId: drafter.id, role: 'DRAFTER' } },
update: {},
create: { bidCaseId: local.id, userId: drafter.id, role: 'DRAFTER' },
});
}
if (bidder) {
await prisma.bidAssignee.upsert({
where: { bidCaseId_userId_role: { bidCaseId: local.id, userId: bidder.id, role: 'BIDDER' } },
update: {},
create: { bidCaseId: local.id, userId: bidder.id, role: 'BIDDER' },
});
}
if (formal.firstNas) {
const exists = await prisma.bidDocVersion.findFirst({ where: { bidCaseId: local.id, versionNo: 1 } });
if (!exists) {
await prisma.bidDocVersion.create({
data: {
bidCaseId: local.id,
versionNo: 1,
nasPath: formal.firstNas,
note: 'OA 正式标书',
submittedById: (drafter || creator).id,
},
});
} else {
await prisma.bidDocVersion.update({
where: { id: exists.id },
data: { nasPath: formal.firstNas },
});
}
}
stats.bids += 1;
stats.bidFiles += announce.n + extra.n + formal.n + proof.n;
}
const contracts = await prisma.contract.findMany();
const contractByName = new Map(contracts.map((c) => [c.name, c]));
for (const c of raw.oa_contract || []) {
const local = contractByName.get(str(c.contract_name));
if (!local) {
console.warn('no local contract', c.contract_name);
continue;
}
const creator = userFromOaId(c.create_by) || userFromOaId(c.creator_id) || admin;
const original = await attach('CONTRACT', local.id, c.contract_original_url, creator.id, seen);
const accept = await attach('CONTRACT_ACCEPT', local.id, c.acceptance_url, creator.id, seen);
const award = await attach('CONTRACT_AWARD', local.id, c.award_notice_url, creator.id, seen);
await prisma.contract.update({
where: { id: local.id },
data: {
nasPath: original.firstNas || accept.firstNas || award.firstNas || local.nasPath,
signedAt: when(c.sign_date) || local.signedAt,
createdById: creator.id,
partyA: local.partyA || str(c.party_a_name) || undefined,
partyB: local.partyB || str(c.party_b_name) || undefined,
remark:
local.remark ||
[str(c.payment_method) && `付款:${c.payment_method}`, str(c.remark)]
.filter(Boolean)
.join(' / ') ||
undefined,
},
});
stats.contracts += 1;
stats.contractFiles += original.n + accept.n + award.n;
}
const expenses = await prisma.expenseClaim.findMany();
for (const r of raw.reimbursement || []) {
const amount = num(r.total_amount);
const name = str(r.employee_name);
const subject = str(r.subject);
let hits = expenses.filter((e) => Math.abs(Number(e.amount) - amount) < 0.009 && (e.remark || '').includes(name));
if (subject) {
const tighter = hits.filter((e) => (e.remark || '').includes(subject));
if (tighter.length) hits = tighter;
}
const stamp = when(r.created_time);
if (stamp && hits.length > 1) {
const day = stamp.toISOString().slice(0, 10);
const byDay = hits.filter((e) => (e.submittedAt || e.createdAt).toISOString().slice(0, 10) === day);
if (byDay.length) hits = byDay;
}
const local = hits[0];
if (!local) continue;
const who = userFromName(name) || users.find((u) => u.id === local.applicantId) || admin;
const items = (raw.reimbursement_item || []).filter((i) => String(i.reimbursement_id) === String(r.id));
let n = 0;
for (const item of items) {
n += (await attach('EXPENSE', local.id, item.url, who.id, seen)).n;
}
n += (await attach('EXPENSE', local.id, r.attachment_url, who.id, seen)).n;
if (n) {
stats.expenses += 1;
stats.expenseFiles += n;
}
}
const projects = await prisma.project.findMany();
const projectByName = new Map(projects.map((p) => [p.name, p]));
for (const p of raw.project_list || []) {
const local = projectByName.get(str(p.project_name));
if (!local) continue;
const n = await attach('PROJECT', local.id, p.bzj_attachment, admin.id, seen);
stats.projects += n.n ? 1 : 0;
}
const purchases = await prisma.purchaseRequest.findMany();
for (const r of raw.oa_purchasing || []) {
const titleBits = [str(r.contract_no), str(r.contract_name)].filter(Boolean);
const local = purchases.find((p) => titleBits.some((b) => p.title.includes(b)));
if (!local) continue;
const who = userFromOaId(r.create_by) || userFromOaId(r.creator_id) || admin;
const a = await attach('PURCHASE', local.id, r.contract_original_url, who.id, seen);
const b = await attach('PURCHASE_ACCEPT', local.id, r.acceptance_url, who.id, seen);
if (a.n + b.n) stats.purchases += 1;
}
const fileCount = await prisma.fileAsset.count();
console.log(JSON.stringify({ ...stats, fileAssetTotal: fileCount }, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+475
View File
@@ -0,0 +1,475 @@
import { PrismaClient, Prisma } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
const DUMP = path.resolve('/opt/import/prod/oa_clean.json');
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 200) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s || undefined;
}
function realPartyName(raw: unknown) {
let name = str(raw).replace(/\s+/g, ' ');
if (!name) return '';
const alias: Record<string, string> = {
'73047': '中国人民解放军73047部队保障部',
'中国人民解放军 32272 部队': '中国人民解放军32272部队',
};
name = alias[name] || name;
if (/^未指定/.test(name)) return '';
if (/^\d+$/.test(name)) return '';
if (name.startsWith('/') || name.endsWith('.pdf')) return '';
if (name.length < 2) return '';
return name;
}
function mapProjectType(v: unknown, projectName?: unknown) {
const name = str(projectName);
if (name.includes('三维')) return '三维';
if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制';
const s = str(v);
if (s.includes('三维')) return '三维';
if (s.includes('摄制') || s.includes('视频')) return '摄制';
if (s.includes('物资')) return '物资';
if (s.includes('集成')) return '集成';
return '软件';
}
function mapBidStatus(row: Row) {
const win = str(row.winning_status);
const st = str(row.project_status);
if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON';
if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST';
if (st === '流标') return 'FAILED';
if (st === '弃标' || win.includes('弃标')) return 'TERMINATED';
return 'PENDING';
}
function mapContractStatus(v: unknown) {
const s = str(v);
if (s === '已通过') return 'APPROVED';
if (s === '审批中') return 'PENDING';
return 'DRAFT';
}
function mapProjectStatus(v: unknown) {
const s = str(v);
if (s === '验收通过') return 'ACCEPTED';
if (s === '已完成') return 'DONE';
if (s === '待验收') return 'INTERNAL_ACCEPTED';
if (s === '未开始') return 'NOT_STARTED';
return 'ACTIVE';
}
function mapExpenseStatus(v: unknown) {
const s = str(v);
if (s === '2') return 'APPROVED';
if (s === '3') return 'REJECTED';
if (s === '1') return 'PENDING';
return 'DRAFT';
}
function mapLoanStatus(row: Row) {
const st = str(row.approval_status);
const offset = num(row.offset_amount);
if (st === '2') {
if (str(row.offset_status) === '2' || offset > 0) return offset >= num(row.amount) ? 'OFFSET' : 'OPEN';
return 'OPEN';
}
if (st === '1') return 'PENDING';
if (st === '3') return 'REJECTED';
return 'DRAFT';
}
function mapSealStatus(v: unknown) {
const s = str(v);
if (s === '2') return 'APPROVED';
if (s === '3') return 'REJECTED';
return 'PENDING';
}
function mapItemCategory(itemType: string) {
if (itemType.includes('交通') || itemType.includes('差旅')) return 'travel';
if (itemType.includes('餐')) return 'meal';
if (itemType.includes('住宿')) return 'stay';
if (itemType.includes('办公')) return 'office';
if (itemType.includes('招待')) return 'entertain';
return 'other';
}
function isBidExpense(itemType: string) {
return itemType.includes('投标') || itemType.includes('制标');
}
async function purgeBusiness() {
await prisma.smsSendLog.deleteMany();
await prisma.operationLog.deleteMany();
await prisma.paymentRecord.deleteMany();
await prisma.projectChange.deleteMany();
await prisma.todoTask.deleteMany();
await prisma.approvalTask.deleteMany();
await prisma.workReport.deleteMany();
await prisma.fileAsset.deleteMany();
await prisma.expenseClaim.deleteMany();
await prisma.loanRecord.deleteMany();
await prisma.bondRecord.deleteMany();
await prisma.invoiceRecord.deleteMany();
await prisma.timesheet.deleteMany();
await prisma.projectAcceptance.deleteMany();
await prisma.projectBudget.deleteMany();
await prisma.projectTask.deleteMany();
await prisma.purchaseRequest.deleteMany();
await prisma.assetOccupancy.deleteMany();
await prisma.contractReviewer.deleteMany();
await prisma.contractActionLog.deleteMany();
await prisma.paymentMilestone.deleteMany();
await prisma.sealRequest.deleteMany();
await prisma.credentialBorrow.deleteMany();
await prisma.project.deleteMany();
await prisma.contract.deleteMany();
await prisma.bidDocVersion.deleteMany();
await prisma.bidAssignee.deleteMany();
await prisma.bidActionLog.deleteMany();
await prisma.bidReviewer.deleteMany();
await prisma.bidCase.deleteMany();
await prisma.partyContact.deleteMany();
await prisma.businessParty.deleteMany();
}
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('本地没有 admin');
await purgeBusiness();
await prisma.legalEntity.updateMany({ data: { isDefault: false } });
let entity = await prisma.legalEntity.findFirst({
where: { tenantId: '1', name: '江苏风影随行科技有限公司' },
});
if (!entity) {
entity = await prisma.legalEntity.create({
data: { name: '江苏风影随行科技有限公司', shortName: '江苏风影', isDefault: true },
});
} else {
entity = await prisma.legalEntity.update({ where: { id: entity.id }, data: { isDefault: true } });
}
const tenders = (raw.oa_tender || []).filter((r) => /^TB-\d/.test(str(r.system_number)) && str(r.project_name));
const partyByName = new Map<string, string>();
const partyNames = new Set<string>();
for (const t of tenders) {
const n = realPartyName(t.tender_dan_wei);
if (n) partyNames.add(n);
}
for (const c of raw.oa_contract || []) {
const n = realPartyName(c.party_a_name);
if (n && n !== '江苏风影随行科技有限公司') partyNames.add(n);
}
for (const name of partyNames) {
const row = await prisma.businessParty.create({
data: { name, remark: 'OA 导入', level: 'B' },
});
partyByName.set(name, row.id);
}
for (const t of tenders) {
const n = realPartyName(t.tender_dan_wei);
const person = str(t.bid_contact_person);
const phone = str(t.bid_contact_phone);
if (!n || (!person && !phone)) continue;
const partyId = partyByName.get(n);
if (!partyId) continue;
const exists = await prisma.partyContact.findFirst({ where: { partyId, name: person || '招标联系人' } });
if (!exists) {
await prisma.partyContact.create({
data: { partyId, name: person || '招标联系人', phone: phone || undefined },
});
}
}
const bidByProd = new Map<string, string>();
const bidByNo = new Map<string, string>();
const bidByName = new Map<string, string>();
for (const t of tenders) {
const bidNo = str(t.system_number);
const partyName = realPartyName(t.tender_dan_wei);
const resultBits = [
t.winning_unit ? `中标单位:${t.winning_unit}` : '',
num(t.winning_amount) ? `中标金额:${num(t.winning_amount)}` : '',
str(t.opening_remark),
str(t.bid_summary),
].filter(Boolean);
const row = await prisma.bidCase.create({
data: {
bidNo,
name: clip(t.project_name, 200)!,
externalNo: clip(t.project_number, 120) || undefined,
projectType: mapProjectType(t.project_type, t.project_name),
source: clip(t.project_source, 50) || undefined,
legalEntityId: entity.id,
partyId: partyName ? partyByName.get(partyName) : undefined,
tenderMethod: clip(t.tender_method, 50) || undefined,
applyEndAt: when(t.file_apply_deadline) || undefined,
openAt: when(t.bid_open_date) || undefined,
openPlace: clip(t.tender_address, 200) || undefined,
securityLevel: str(t.security_level) || 'INTERNAL',
projectNature: clip(t.project_nature, 50) || undefined,
contactName: clip(t.bid_contact_person, 64) || undefined,
contactPhone: clip(t.bid_contact_phone, 32) || undefined,
bondMethod: clip(t.deposit_pay_method, 20) || undefined,
bondStatus: clip(t.deposit_status, 20) || undefined,
bondPaidAt: when(t.deposit_pay_date) || undefined,
delivererName: clip(t.bid_delivery_person, 64) || undefined,
priceCap: t.price_limit == null ? undefined : String(t.price_limit),
quoteAmount: t.bid_quotation == null ? undefined : String(t.bid_quotation),
summary: clip(t.project_intro, 2000) || undefined,
remark: clip(t.remark, 1000) || undefined,
resultNote: resultBits.join('') || undefined,
openedResultAt: when(t.winning_date) || when(t.bid_open_date) || undefined,
status: mapBidStatus(t),
createdById: admin.id,
createdAt: when(t.create_time) || undefined,
},
});
bidByProd.set(String(t.id), row.id);
bidByNo.set(bidNo, row.id);
bidByName.set(str(t.project_name), row.id);
const deposit = num(t.bid_deposit);
if (deposit > 0) {
const paid = Boolean(when(t.deposit_pay_date));
const returned = Boolean(when(t.deposit_refund_date));
await prisma.bondRecord.create({
data: {
bondNo: `BZJ-${bidNo}`,
bidCaseId: row.id,
bondType: 'TENDER',
amount: new Prisma.Decimal(deposit),
paidAt: when(t.deposit_pay_date) || undefined,
returnedAt: when(t.deposit_refund_date) || undefined,
status: returned ? 'RETURNED' : paid ? 'PAID' : 'UNPAID',
remark: 'OA 投标保证金',
},
});
}
}
const contractByProd = new Map<string, string>();
const contractByNo = new Map<string, string>();
const contractByName = new Map<string, string>();
for (const c of raw.oa_contract || []) {
const contractNo = str(c.contract_no);
if (!contractNo) continue;
const tb = contractNo.match(/TB-\d+-\d+/);
const bidCaseId = tb ? bidByNo.get(tb[0]) : undefined;
if (str(c.approval_status) === 'pending_submit' && !c.sign_date && !num(c.amount)) {
continue;
}
const partyA = realPartyName(c.party_a_name);
const partyB = str(c.party_b_name);
if (partyA && !partyByName.has(partyA) && partyA !== '江苏风影随行科技有限公司') {
const p = await prisma.businessParty.create({ data: { name: partyA, remark: 'OA 合同甲方', level: 'B' } });
partyByName.set(partyA, p.id);
}
const remark = [
str(c.payment_method) ? `付款:${c.payment_method}` : '',
str(c.contract_lb) ? `类别:${c.contract_lb}` : '',
str(c.remark),
]
.filter(Boolean)
.join(' / ');
const row = await prisma.contract.create({
data: {
contractNo,
name: clip(c.contract_name, 200)!,
bidCaseId,
legalEntityId: entity.id,
amount: new Prisma.Decimal(num(c.amount)),
status: mapContractStatus(c.approval_status),
partyA: partyA || undefined,
partyB: partyB || undefined,
remark: remark || undefined,
signedAt: when(c.sign_date) || undefined,
createdById: admin.id,
createdAt: when(c.create_time) || undefined,
},
});
contractByProd.set(String(c.id), row.id);
contractByNo.set(contractNo, row.id);
contractByName.set(str(c.contract_name), row.id);
}
const projectByProd = new Map<string, string>();
const projectByName = new Map<string, string>();
for (const p of raw.project_list || []) {
const projectNo = str(p.system_number) || str(p.project_code);
if (!projectNo || !str(p.project_name)) continue;
const contractId =
(p.contract_id != null ? contractByProd.get(String(p.contract_id)) : undefined) ||
(str(p.contract_name) ? contractByName.get(str(p.contract_name)) : undefined);
const row = await prisma.project.create({
data: {
projectNo,
name: clip(p.project_name, 200)!,
contractId,
status: mapProjectStatus(p.project_status),
createdAt: when(p.create_time) || undefined,
},
});
projectByProd.set(String(p.project_id), row.id);
projectByName.set(str(p.project_name), row.id);
const budget = num(p.project_budget);
if (budget > 0) {
await prisma.projectBudget.create({
data: { projectId: row.id, category: 'PURCHASE', amount: new Prisma.Decimal(budget) },
});
}
const bzj = num(p.project_bzj);
if (bzj > 0) {
await prisma.bondRecord.create({
data: {
bondNo: `BZJ-${projectNo}`,
projectId: row.id,
contractId,
bondType: 'WARRANTY',
amount: new Prisma.Decimal(bzj),
dueBackAt: when(p.bzj_date) || undefined,
status: 'UNPAID',
remark: str(p.bzj_type) ? `OA 项目质保金 ${p.bzj_type}` : 'OA 项目质保金',
},
});
}
}
const itemsByReimb = new Map<string, Row[]>();
for (const item of raw.reimbursement_item || []) {
const key = String(item.reimbursement_id);
if (!itemsByReimb.has(key)) itemsByReimb.set(key, []);
itemsByReimb.get(key)!.push(item);
}
for (const r of raw.reimbursement || []) {
const claimNo = str(r.reimburse_no);
if (!claimNo) continue;
const lines = itemsByReimb.get(String(r.id)) || [];
const main = [...lines].sort((a, b) => num(b.amount) - num(a.amount))[0];
const itemType = str(main?.item_type);
const cat = mapItemCategory(itemType);
const projectId = r.project_id != null ? projectByProd.get(String(r.project_id)) : projectByName.get(str(r.project_name));
const bidCaseId = isBidExpense(itemType)
? bidByName.get(str(r.project_name))
: !projectId
? bidByName.get(str(r.project_name))
: undefined;
const costType = isBidExpense(itemType) || bidCaseId ? 'BID' : projectId ? 'PROJECT' : 'DEPT';
await prisma.expenseClaim.create({
data: {
claimNo,
applicantId: admin.id,
costType,
budgetCategory: costType === 'PROJECT' ? (cat === 'office' ? 'PURCHASE' : 'TRAVEL') : undefined,
projectId: costType === 'PROJECT' ? projectId : undefined,
bidCaseId: costType === 'BID' ? bidCaseId : undefined,
expenseCategory: cat,
purpose: clip(r.subject || r.project_name, 200) || undefined,
amount: new Prisma.Decimal(num(r.total_amount)),
status: mapExpenseStatus(r.approval_status),
remark: clip(r.remark, 1000) || undefined,
submittedAt: when(r.created_time) || undefined,
decidedAt: str(r.approval_status) === '2' ? when(r.created_time) || undefined : undefined,
decidedById: str(r.approval_status) === '2' ? admin.id : undefined,
},
});
}
for (const r of raw.borrow_advance || []) {
const loanNo = str(r.advance_no);
if (!loanNo) continue;
const projectId = r.project_id != null ? projectByProd.get(String(r.project_id)) : projectByName.get(str(r.project_name));
await prisma.loanRecord.create({
data: {
loanNo,
applicantId: admin.id,
costType: projectId ? 'PROJECT' : 'DEPT',
projectId,
amount: new Prisma.Decimal(num(r.amount)),
offsetAmount: new Prisma.Decimal(num(r.offset_amount)),
purpose: clip(r.reason || r.subject, 200) || 'OA 借款',
status: mapLoanStatus(r),
remark: undefined,
submittedAt: when(r.apply_time) || undefined,
},
});
}
for (const r of raw.oa_seal_apply || []) {
const reason = clip(r.content || r.apply_subject, 500) || '用章';
const bidHit = tenders.find((t) => str(t.project_name) && reason.includes(str(t.project_name)));
await prisma.sealRequest.create({
data: {
sealType: clip(r.seal_type, 40) || '公章',
reason,
bidCaseId: bidHit ? bidByProd.get(String(bidHit.id)) : undefined,
applicantId: admin.id,
takeOut: str(r.is_borrow) === '是',
returnAt: when(r.borrow_end) || undefined,
status: mapSealStatus(r.approval_status),
createdAt: when(r.create_time) || undefined,
},
});
}
for (const r of raw.oa_purchasing || []) {
const title = [str(r.contract_no), str(r.contract_name)].filter(Boolean).join(' ');
if (!title) continue;
await prisma.purchaseRequest.create({
data: {
title: clip(title, 200)!,
amount: new Prisma.Decimal(num(r.amount)),
status: str(r.approval_status) === '已通过' ? 'APPROVED' : str(r.approval_status) === '审批中' ? 'PENDING' : 'DRAFT',
remark: [r.party_a_name, r.payment_method, r.sign_date, r.remark].map(str).filter(Boolean).join(' / ') || undefined,
applicantId: admin.id,
},
});
}
const counts = {
parties: partyByName.size,
bids: tenders.length,
contracts: (raw.oa_contract || []).length,
projects: (raw.project_list || []).length,
expenses: (raw.reimbursement || []).length,
loans: (raw.borrow_advance || []).length,
seals: (raw.oa_seal_apply || []).length,
purchases: (raw.oa_purchasing || []).length,
};
console.log(JSON.stringify(counts, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+239
View File
@@ -0,0 +1,239 @@
import { createReadStream } from 'fs';
import { createGunzip } from 'zlib';
import { PrismaClient } from '@prisma/client';
import { parseOaJson } from './oa-json';
const prisma = new PrismaClient();
const DUMP = process.env.OA_DUMP || '/opt/backup/cloud-20260830/oa.sql.gz';
const HR = '/opt/import/prod/oa_hr.json';
function n(v: unknown) {
if (v == null || v === '') return 0;
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
function parseMysqlTuples(s: string): unknown[][] {
const rows: unknown[][] = [];
let i = 0;
while (i < s.length) {
while (i < s.length && ' \n\r\t,'.includes(s[i])) i += 1;
if (i >= s.length) break;
if (s[i] !== '(') throw new Error(`expected ( at ${i}`);
i += 1;
const row: unknown[] = [];
while (true) {
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s.startsWith('NULL', i) && (i + 4 === s.length || ',) \n\r\t'.includes(s[i + 4]))) {
row.push(null);
i += 4;
} else if (s[i] === "'" || s[i] === '"') {
const q = s[i];
i += 1;
let out = '';
while (i < s.length) {
const ch = s[i];
if (ch === '\\') {
out += s[i + 1];
i += 2;
continue;
}
if (ch === q) {
if (s[i + 1] === q) {
out += q;
i += 2;
continue;
}
i += 1;
break;
}
out += ch;
i += 1;
}
row.push(out);
} else {
const start = i;
while (i < s.length && s[i] !== ',' && s[i] !== ')') i += 1;
const raw = s.slice(start, i).trim();
row.push(raw === '' ? null : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw);
}
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s[i] === ',') {
i += 1;
continue;
}
if (s[i] === ')') {
i += 1;
rows.push(row);
break;
}
throw new Error(`bad token at ${i}`);
}
}
return rows;
}
function insertsFor(dump: string, table: string) {
const rows: unknown[][] = [];
const needle = `INSERT INTO \`${table}\` VALUES `;
let from = 0;
while (true) {
const start = dump.indexOf(needle, from);
if (start < 0) break;
let i = start + needle.length;
while (i < dump.length) {
while (i < dump.length && ' \n\r\t'.includes(dump[i])) i += 1;
if (dump[i] === ';') {
i += 1;
break;
}
if (dump[i] === ',') {
i += 1;
continue;
}
if (dump[i] !== '(') break;
const sliceStart = i;
let depth = 0;
let inStr = false;
let q = '';
while (i < dump.length) {
const ch = dump[i];
if (inStr) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === q) inStr = false;
i += 1;
continue;
}
if (ch === "'" || ch === '"') {
inStr = true;
q = ch;
i += 1;
continue;
}
if (ch === '(') depth += 1;
if (ch === ')') {
depth -= 1;
if (depth === 0) {
i += 1;
break;
}
}
i += 1;
}
rows.push(...parseMysqlTuples(dump.slice(sliceStart, i)));
}
from = i;
}
return rows;
}
async function readDump() {
return new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
createReadStream(DUMP)
.pipe(createGunzip())
.on('data', (c: Buffer) => chunks.push(c))
.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
.on('error', reject);
});
}
async function main() {
const dump = await readDump();
const hr = parseOaJson(HR) as { users?: { id: number; nickName?: string; phone?: string }[] };
const employees = await prisma.employee.findMany({ select: { id: true, name: true, mobile: true } });
const byName = new Map(employees.map((e) => [e.name, e]));
const byMobile = new Map(employees.filter((e) => e.mobile).map((e) => [e.mobile as string, e]));
const oaUserToEmp = new Map<number, string>();
for (const u of hr.users || []) {
const hit = (u.phone && byMobile.get(String(u.phone))) || (u.nickName && byName.get(u.nickName));
if (hit) oaUserToEmp.set(Number(u.id), hit.id);
}
const months = insertsFor(dump, 'oa_salary_month');
const slips = insertsFor(dump, 'oa_salary_slip');
const admin = await prisma.user.findFirst({ where: { username: '18115604840' } });
await prisma.payrollItem.deleteMany();
await prisma.payrollRun.deleteMany();
const runByYm = new Map<string, string>();
for (const row of months) {
const ym = String(row[1] || '');
if (!/^\d{4}-\d{2}$/.test(ym)) continue;
const confirmed = Number(row[2]) === 1;
const run = await prisma.payrollRun.create({
data: {
tenantId: '1',
yearMonth: ym,
status: confirmed ? 'CONFIRMED' : 'DRAFT',
createdById: admin?.id,
confirmedAt: confirmed ? (row[7] ? new Date(String(row[7])) : new Date()) : null,
},
});
runByYm.set(ym, run.id);
}
let ok = 0;
let skip = 0;
for (const row of slips) {
const ym = String(row[2] || '');
const oaUserId = Number(row[3]);
const name = String(row[4] || '');
let empId = oaUserToEmp.get(oaUserId);
if (!empId && name) empId = byName.get(name)?.id;
if (!empId || !runByYm.has(ym)) {
skip += 1;
continue;
}
await prisma.payrollItem.upsert({
where: { runId_employeeId: { runId: runByYm.get(ym) as string, employeeId: empId } },
update: {
basePay: n(row[9]) + n(row[10]),
performancePay: n(row[18] || row[11]),
leaveDeduct: n(row[22]),
sickDeduct: n(row[23]),
socialPersonal: n(row[27]),
tax: n(row[28]),
grossPay: n(row[16]),
netPay: n(row[30]),
scheduledDays: n(row[31]),
prorationFactor: n(row[29]) || 1,
},
create: {
runId: runByYm.get(ym) as string,
employeeId: empId,
basePay: n(row[9]) + n(row[10]),
performancePay: n(row[18] || row[11]),
leaveDeduct: n(row[22]),
sickDeduct: n(row[23]),
socialPersonal: n(row[27]),
tax: n(row[28]),
grossPay: n(row[16]),
netPay: n(row[30]),
scheduledDays: n(row[31]),
prorationFactor: n(row[29]) || 1,
},
});
ok += 1;
}
console.log(
JSON.stringify({
months: runByYm.size,
slips: slips.length,
imported: ok,
skipped: skip,
}),
);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+201
View File
@@ -0,0 +1,201 @@
export type MenuNode = {
name: string;
code: string;
path: string;
icon?: string;
children?: MenuNode[];
};
/** 人事业务在「组织人事」;组织/班次/参数进「系统设置 · 人事设置」。 */
export const MENUS: MenuNode[] = [
{
name: '工作台',
code: 'office:overview',
path: '/workbench',
icon: 'HomeOutlined',
},
{
name: '个人办公',
code: 'office',
path: '/office/todos',
icon: 'AppstoreOutlined',
children: [
{ name: '待办事项', code: 'office:todos', path: '/office/todos' },
{ name: '待我审批', code: 'office:approvals', path: '/office/approvals' },
{ name: '我的申请', code: 'office:flow', path: '/office/flow' },
{ name: '我的日程', code: 'office:calendar', path: '/office/calendar' },
{ name: '工作汇报', code: 'office:reports', path: '/office/reports' },
{ name: '我的费用', code: 'office:expenses', path: '/office/expenses' },
{ name: '人事申请', code: 'office:hr-apply', path: '/office/hr-apply' },
{ name: '用章申请', code: 'office:seal-apply', path: '/office/seal-apply' },
{ name: '办公申请', code: 'office:apply', path: '/office/apply' },
{ name: '公司公告', code: 'office:notices', path: '/office/notices' },
{ name: '发送短信', code: 'office:sms', path: '/office/sms' },
{ name: '工作安排', code: 'office:assign', path: '/office/assign' },
{ name: '消息', code: 'office:im', path: '/office/im' },
{ name: '员工通讯', code: 'office:directory', path: '/office/directory' },
],
},
{
name: '往来单位',
code: 'party',
path: '/party/tenders',
icon: 'ClusterOutlined',
children: [
{ name: '招标单位', code: 'party:tenders', path: '/party/tenders' },
{ name: '参与主体', code: 'party:entities', path: '/party/entities' },
{ name: '联系名录', code: 'party:contacts', path: '/party/contacts' },
],
},
{
name: '证照用章',
code: 'seal',
path: '/seal/qualifications',
icon: 'SafetyCertificateOutlined',
children: [
{ name: '资质台账', code: 'seal:qualifications', path: '/seal/qualifications' },
{ name: '证照借用', code: 'seal:borrows', path: '/seal/borrows' },
{ name: '用章登记', code: 'seal:requests', path: '/seal/requests' },
{ name: '印章台账', code: 'seal:registry', path: '/seal/registry' },
],
},
{
name: '投标管理',
code: 'bid',
path: '/bid/screening',
icon: 'AuditOutlined',
children: [
{ name: '项目筛选', code: 'bid:screening', path: '/bid/screening' },
{ name: '特殊筛选', code: 'bid:special-screening', path: '/bid/special-screening' },
{ name: '标书制作', code: 'bid:drafting', path: '/bid/drafting' },
{ name: '特殊制标', code: 'bid:special-drafting', path: '/bid/special-drafting' },
{ name: '投标执行', code: 'bid:execution', path: '/bid/execution' },
{ name: '开标结果', code: 'bid:result', path: '/bid/result' },
{ name: '终止投标', code: 'bid:terminated', path: '/bid/terminated' },
],
},
{
name: '合同管理',
code: 'contract',
path: '/contract/tender',
icon: 'FileProtectOutlined',
children: [
{ name: '投标合同', code: 'contract:approval', path: '/contract/tender' },
{ name: '直签合同', code: 'contract:direct', path: '/contract/direct' },
{ name: '其他合同', code: 'contract:list', path: '/contract/incremental' },
{ name: '人事合同', code: 'contract:hr', path: '/contract/hr' },
],
},
{
name: '资产管理',
code: 'asset',
path: '/asset/digital',
icon: 'DatabaseOutlined',
children: [
{ name: '数字资产', code: 'asset:digital', path: '/asset/digital' },
{ name: '固定资产', code: 'asset:fixed', path: '/asset/fixed' },
],
},
{
name: '项目管理',
code: 'project',
path: '/project/list',
icon: 'ProjectOutlined',
children: [
{ name: '项目台账', code: 'project:list', path: '/project/list' },
{ name: '任务分解', code: 'project:tasks', path: '/project/tasks' },
{ name: '工时填报', code: 'project:timesheets', path: '/project/timesheets' },
{ name: '资源看板', code: 'project:resources', path: '/project/resources' },
{ name: '项目验收', code: 'project:acceptance', path: '/project/acceptance' },
{ name: '采购申请', code: 'project:purchases', path: '/project/purchases' },
{ name: '器材占用', code: 'project:assets', path: '/project/assets' },
],
},
{
name: '财务收支',
code: 'finance',
path: '/finance/expenses',
icon: 'AccountBookOutlined',
children: [
{ name: '费用报销', code: 'finance:expenses', path: '/finance/expenses' },
{ name: '借支记录', code: 'finance:loans', path: '/finance/loans' },
{ name: '资产购置台账', code: 'finance:asset-purchases', path: '/finance/asset-purchases' },
{ name: '投标保证金', code: 'finance:tender-bonds', path: '/finance/bonds/tender' },
{ name: '履约保证金', code: 'finance:perf-bonds', path: '/finance/bonds/performance' },
{ name: '项目质保金', code: 'finance:warranty-bonds', path: '/finance/bonds/warranty' },
{ name: '开票收款', code: 'finance:invoices', path: '/finance/invoices' },
{ name: '付款执行', code: 'finance:payments', path: '/finance/payments' },
],
},
{
name: '组织人事',
code: 'hr',
path: '/hr/employees',
icon: 'TeamOutlined',
children: [
{ name: '在职员工', code: 'hr:employees', path: '/hr/employees' },
{ name: '组织管理', code: 'hr:org', path: '/hr/org' },
{ name: '入转调离', code: 'hr:lifecycle', path: '/hr/lifecycle' },
{ name: '假勤结果', code: 'hr:attendance', path: '/hr/attendance' },
{ name: '薪酬核算', code: 'hr:payroll', path: '/hr/payroll' },
{ name: '绩效发放', code: 'hr:performance', path: '/hr/performance' },
{ name: '发布公告', code: 'hr:notices', path: '/hr/notices' },
{ name: '规章制度', code: 'hr:policies', path: '/hr/policies' },
],
},
{
name: '经营报表',
code: 'report',
path: '/report/conversion',
icon: 'BarChartOutlined',
children: [
{ name: '投标转化', code: 'report:conversion', path: '/report/conversion' },
{ name: '落标对照', code: 'report:lost', path: '/report/lost' },
{ name: '项目利润', code: 'report:profit', path: '/report/profit' },
{ name: '部门人效', code: 'report:efficiency', path: '/report/efficiency' },
{ name: '资金占用', code: 'report:cash', path: '/report/cash' },
],
},
{
name: '系统设置',
code: 'system',
path: '/system/acl',
icon: 'SettingOutlined',
children: [
{
name: '人事设置',
code: 'system:hr',
path: '/system/hr-params',
children: [
{ name: '员工账号', code: 'system:acl', path: '/system/acl' },
{ name: '人事参数', code: 'system:hr-params', path: '/system/hr-params' },
{ name: '考勤设置', code: 'system:attendance-rules', path: '/system/attendance-rules' },
],
},
{ name: '数据字典', code: 'system:dicts', path: '/system/dicts' },
{ name: '权限设置', code: 'system:perms', path: '/system/perms' },
{ name: '编号规则', code: 'system:numbers', path: '/system/numbers' },
{ name: '短信服务', code: 'system:sms', path: '/system/sms' },
{ name: '消息通道', code: 'system:messages', path: '/system/messages' },
{ name: '手机端升级', code: 'system:app-release', path: '/system/app-release' },
{ name: '操作日志', code: 'system:logs', path: '/system/logs' },
],
},
];
export function walkMenus(nodes: MenuNode[], visit: (node: MenuNode, parent: MenuNode | null, sortNo: number) => void, parent: MenuNode | null = null) {
nodes.forEach((node, i) => {
visit(node, parent, parent ? i + 1 : (i + 1) * 10);
if (node.children?.length) walkMenus(node.children, visit, node);
});
}
export function isHrSettingCode(code: string) {
return (
code === 'system:acl' ||
code === 'system:hr' ||
code.startsWith('system:hr') ||
code === 'system:org' ||
code === 'system:attendance-rules'
);
}
@@ -0,0 +1,567 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"employeeId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Department" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"parentId" TEXT,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"deptType" TEXT NOT NULL,
"categoryCode" TEXT,
"sortNo" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Employee" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"employeeNo" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"title" TEXT,
"restSchedule" TEXT NOT NULL DEFAULT 'SINGLE',
"employmentStatus" TEXT NOT NULL DEFAULT 'REGULAR',
"hiredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Employee_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"permType" TEXT NOT NULL,
"path" TEXT,
"icon" TEXT,
"sortNo" INTEGER NOT NULL DEFAULT 0,
"visible" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId","permissionId")
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("userId","roleId")
);
-- CreateTable
CREATE TABLE "DictItem" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"dictType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"value" TEXT NOT NULL,
"sortNo" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "DictItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LegalEntity" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"shortName" TEXT NOT NULL,
"creditNo" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LegalEntity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessParty" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"creditNo" TEXT,
"level" TEXT NOT NULL DEFAULT 'B',
"riskFlag" BOOLEAN NOT NULL DEFAULT false,
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BusinessParty_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartyContact" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"partyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"title" TEXT,
CONSTRAINT "PartyContact_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Qualification" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3),
"keeper" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Qualification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SealRequest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"sealType" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"bidCaseId" TEXT,
"takeOut" BOOLEAN NOT NULL DEFAULT false,
"returnAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SealRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CredentialBorrow" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"itemName" TEXT NOT NULL,
"borrower" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"returnedAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'BORROWED',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CredentialBorrow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BidCase" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"bidNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"externalNo" TEXT,
"projectType" TEXT NOT NULL,
"sourceType" TEXT NOT NULL DEFAULT 'FLOW',
"source" TEXT,
"qualificationNeed" TEXT,
"legalEntityId" TEXT,
"tenderMethod" TEXT,
"applyStartAt" TIMESTAMP(3),
"applyEndAt" TIMESTAMP(3),
"applyMethod" TEXT,
"openAt" TIMESTAMP(3),
"openMethod" TEXT,
"openPlace" TEXT,
"securityLevel" TEXT NOT NULL DEFAULT 'INTERNAL',
"projectNature" TEXT,
"partyId" TEXT,
"contactName" TEXT,
"contactPhone" TEXT,
"bondMethod" TEXT,
"bondStatus" TEXT,
"bondPaidAt" TIMESTAMP(3),
"delivererName" TEXT,
"priceCap" TEXT,
"quoteAmount" TEXT,
"summary" TEXT,
"remark" TEXT,
"status" TEXT NOT NULL DEFAULT 'TECH_INITIAL',
"nasPath" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BidCase_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BidReviewer" (
"id" TEXT NOT NULL,
"bidCaseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"stage" TEXT NOT NULL,
"result" TEXT NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
CONSTRAINT "BidReviewer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TodoTask" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"bizType" TEXT NOT NULL,
"bizId" TEXT,
"assigneeId" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TodoTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApprovalTask" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"bizType" TEXT NOT NULL,
"bizId" TEXT,
"assigneeId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApprovalTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkReport" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"authorId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Contract" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"contractNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"bidCaseId" TEXT,
"amount" DECIMAL(18,2) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"signedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Contract_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentMilestone" (
"id" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ratio" DECIMAL(5,2) NOT NULL,
"dueAt" TIMESTAMP(3),
"trigger" TEXT,
CONSTRAINT "PaymentMilestone_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"projectNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"contractId" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectTask" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"department" TEXT,
"dueAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'OPEN',
CONSTRAINT "ProjectTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Timesheet" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"projectId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"workDate" TIMESTAMP(3) NOT NULL,
"hours" DECIMAL(6,2) NOT NULL,
"note" TEXT,
CONSTRAINT "Timesheet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExpenseClaim" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"claimNo" TEXT NOT NULL,
"applicantId" TEXT NOT NULL,
"costType" TEXT NOT NULL,
"bidCaseId" TEXT,
"projectId" TEXT,
"amount" DECIMAL(18,2) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExpenseClaim_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoanRecord" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"applicantId" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"purpose" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoanRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BondRecord" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"bidCaseId" TEXT NOT NULL,
"bondType" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"paidAt" TIMESTAMP(3),
"dueBackAt" TIMESTAMP(3),
"returnedAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'UNPAID',
CONSTRAINT "BondRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InvoiceRecord" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"invoiceNo" TEXT,
"amount" DECIMAL(18,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'TO_ISSUE',
"issuedAt" TIMESTAMP(3),
"receivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InvoiceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PurchaseRequest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"projectId" TEXT,
"amount" DECIMAL(18,2) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PurchaseRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AssetOccupancy" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"assetName" TEXT NOT NULL,
"holder" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'IN_USE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssetOccupancy_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OperationLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"userId" TEXT,
"action" TEXT NOT NULL,
"path" TEXT NOT NULL,
"requestId" TEXT,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OperationLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_tenantId_username_key" ON "User"("tenantId", "username");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Department_tenantId_code_key" ON "Department"("tenantId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Employee_tenantId_employeeNo_key" ON "Employee"("tenantId", "employeeNo");
-- CreateIndex
CREATE UNIQUE INDEX "Role_tenantId_code_key" ON "Role"("tenantId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_code_key" ON "Permission"("code");
-- CreateIndex
CREATE UNIQUE INDEX "DictItem_tenantId_dictType_value_key" ON "DictItem"("tenantId", "dictType", "value");
-- CreateIndex
CREATE UNIQUE INDEX "LegalEntity_tenantId_name_key" ON "LegalEntity"("tenantId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessParty_tenantId_name_key" ON "BusinessParty"("tenantId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "BidCase_tenantId_bidNo_key" ON "BidCase"("tenantId", "bidNo");
-- CreateIndex
CREATE INDEX "BidReviewer_bidCaseId_idx" ON "BidReviewer"("bidCaseId");
-- CreateIndex
CREATE UNIQUE INDEX "Contract_tenantId_contractNo_key" ON "Contract"("tenantId", "contractNo");
-- CreateIndex
CREATE UNIQUE INDEX "Project_tenantId_projectNo_key" ON "Project"("tenantId", "projectNo");
-- CreateIndex
CREATE UNIQUE INDEX "ExpenseClaim_tenantId_claimNo_key" ON "ExpenseClaim"("tenantId", "claimNo");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Department" ADD CONSTRAINT "Department_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Permission"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartyContact" ADD CONSTRAINT "PartyContact_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "BusinessParty"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "BusinessParty"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidReviewer" ADD CONSTRAINT "BidReviewer_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TodoTask" ADD CONSTRAINT "TodoTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApprovalTask" ADD CONSTRAINT "ApprovalTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Contract" ADD CONSTRAINT "Contract_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentMilestone" ADD CONSTRAINT "PaymentMilestone_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
-- AlterTable
ALTER TABLE "BidCase" ADD COLUMN "fetchedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "BidReviewer" ADD COLUMN "decidedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "BidActionLog" (
"id" TEXT NOT NULL,
"bidCaseId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BidActionLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BidReviewer_bidCaseId_userId_stage_key" ON "BidReviewer"("bidCaseId", "userId", "stage");
-- CreateIndex
CREATE INDEX "BidActionLog_bidCaseId_idx" ON "BidActionLog"("bidCaseId");
-- AddForeignKey
ALTER TABLE "BidReviewer" ADD CONSTRAINT "BidReviewer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidActionLog" ADD CONSTRAINT "BidActionLog_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidActionLog" ADD CONSTRAINT "BidActionLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,50 @@
-- AlterTable
ALTER TABLE "BidCase" ADD COLUMN "reviewCycle" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "BidCase" ADD COLUMN "printCost" DECIMAL(18,2);
ALTER TABLE "BidCase" ADD COLUMN "printNote" TEXT;
ALTER TABLE "BidCase" ADD COLUMN "printedAt" TIMESTAMP(3);
ALTER TABLE "BidCase" ADD COLUMN "costDisposition" TEXT;
ALTER TABLE "BidCase" ADD COLUMN "resultNote" TEXT;
ALTER TABLE "BidCase" ADD COLUMN "openedResultAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "BidReviewer" ADD COLUMN "cycle" INTEGER NOT NULL DEFAULT 1;
DROP INDEX IF EXISTS "BidReviewer_bidCaseId_userId_stage_key";
CREATE UNIQUE INDEX "BidReviewer_bidCaseId_userId_stage_cycle_key" ON "BidReviewer"("bidCaseId", "userId", "stage", "cycle");
-- CreateTable
CREATE TABLE "BidAssignee" (
"id" TEXT NOT NULL,
"bidCaseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BidAssignee_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "BidAssignee_bidCaseId_userId_role_key" ON "BidAssignee"("bidCaseId", "userId", "role");
CREATE INDEX "BidAssignee_bidCaseId_idx" ON "BidAssignee"("bidCaseId");
ALTER TABLE "BidAssignee" ADD CONSTRAINT "BidAssignee_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BidAssignee" ADD CONSTRAINT "BidAssignee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "BidDocVersion" (
"id" TEXT NOT NULL,
"bidCaseId" TEXT NOT NULL,
"versionNo" INTEGER NOT NULL,
"nasPath" TEXT NOT NULL,
"note" TEXT,
"submittedById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BidDocVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "BidDocVersion_bidCaseId_versionNo_key" ON "BidDocVersion"("bidCaseId", "versionNo");
CREATE INDEX "BidDocVersion_bidCaseId_idx" ON "BidDocVersion"("bidCaseId");
ALTER TABLE "BidDocVersion" ADD CONSTRAINT "BidDocVersion_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BidDocVersion" ADD CONSTRAINT "BidDocVersion_submittedById_fkey" FOREIGN KEY ("submittedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,56 @@
-- AlterTable
ALTER TABLE "Contract" ADD COLUMN "legalEntityId" TEXT;
ALTER TABLE "Contract" ADD COLUMN "nasPath" TEXT;
ALTER TABLE "Contract" ADD COLUMN "remark" TEXT;
ALTER TABLE "Contract" ADD COLUMN "submittedAt" TIMESTAMP(3);
ALTER TABLE "Contract" ADD COLUMN "approvedAt" TIMESTAMP(3);
ALTER TABLE "Contract" ADD COLUMN "createdById" TEXT;
ALTER TABLE "Contract" ADD CONSTRAINT "Contract_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Contract" ADD CONSTRAINT "Contract_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "SealRequest" ADD COLUMN "contractId" TEXT;
ALTER TABLE "SealRequest" ADD COLUMN "applicantId" TEXT;
ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "presalesCost" DECIMAL(18,2) NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "ContractReviewer" (
"id" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"result" TEXT NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
"decidedAt" TIMESTAMP(3),
CONSTRAINT "ContractReviewer_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ContractReviewer_contractId_userId_key" ON "ContractReviewer"("contractId", "userId");
CREATE INDEX "ContractReviewer_contractId_idx" ON "ContractReviewer"("contractId");
ALTER TABLE "ContractReviewer" ADD CONSTRAINT "ContractReviewer_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ContractReviewer" ADD CONSTRAINT "ContractReviewer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "ContractActionLog" (
"id" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ContractActionLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ContractActionLog_contractId_idx" ON "ContractActionLog"("contractId");
ALTER TABLE "ContractActionLog" ADD CONSTRAINT "ContractActionLog_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ContractActionLog" ADD CONSTRAINT "ContractActionLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,67 @@
-- ExpenseClaim
ALTER TABLE "ExpenseClaim" ADD COLUMN "loanId" TEXT;
ALTER TABLE "ExpenseClaim" ADD COLUMN "submittedAt" TIMESTAMP(3);
ALTER TABLE "ExpenseClaim" ADD COLUMN "decidedAt" TIMESTAMP(3);
ALTER TABLE "ExpenseClaim" ADD COLUMN "decidedById" TEXT;
ALTER TABLE "ExpenseClaim" ADD COLUMN "rejectComment" TEXT;
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_loanId_fkey" FOREIGN KEY ("loanId") REFERENCES "LoanRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "ExpenseClaim_applicantId_idx" ON "ExpenseClaim"("applicantId");
CREATE INDEX "ExpenseClaim_loanId_idx" ON "ExpenseClaim"("loanId");
-- LoanRecord
ALTER TABLE "LoanRecord" ADD COLUMN "loanNo" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "costType" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "bidCaseId" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "projectId" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "offsetAmount" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "LoanRecord" ADD COLUMN "remark" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "submittedAt" TIMESTAMP(3);
ALTER TABLE "LoanRecord" ADD COLUMN "decidedAt" TIMESTAMP(3);
ALTER TABLE "LoanRecord" ADD COLUMN "decidedById" TEXT;
UPDATE "LoanRecord" SET "loanNo" = 'JK-LEGACY-' || substr("id", 1, 8) WHERE "loanNo" IS NULL;
UPDATE "LoanRecord" SET "costType" = 'ADMIN' WHERE "costType" IS NULL;
ALTER TABLE "LoanRecord" ALTER COLUMN "loanNo" SET NOT NULL;
ALTER TABLE "LoanRecord" ALTER COLUMN "costType" SET NOT NULL;
ALTER TABLE "LoanRecord" ALTER COLUMN "status" SET DEFAULT 'DRAFT';
CREATE UNIQUE INDEX "LoanRecord_tenantId_loanNo_key" ON "LoanRecord"("tenantId", "loanNo");
CREATE INDEX "LoanRecord_applicantId_idx" ON "LoanRecord"("applicantId");
ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- BondRecord
ALTER TABLE "BondRecord" ADD COLUMN "bondNo" TEXT;
ALTER TABLE "BondRecord" ADD COLUMN "projectId" TEXT;
ALTER TABLE "BondRecord" ADD COLUMN "contractId" TEXT;
ALTER TABLE "BondRecord" ADD COLUMN "remark" TEXT;
ALTER TABLE "BondRecord" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
UPDATE "BondRecord" SET "bondNo" = 'BZ-LEGACY-' || substr("id", 1, 8) WHERE "bondNo" IS NULL;
ALTER TABLE "BondRecord" ALTER COLUMN "bondNo" SET NOT NULL;
ALTER TABLE "BondRecord" ALTER COLUMN "bidCaseId" DROP NOT NULL;
ALTER TABLE "BondRecord" DROP CONSTRAINT "BondRecord_bidCaseId_fkey";
ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE UNIQUE INDEX "BondRecord_tenantId_bondNo_key" ON "BondRecord"("tenantId", "bondNo");
-- InvoiceRecord
DELETE FROM "InvoiceRecord";
ALTER TABLE "InvoiceRecord" ADD COLUMN "contractId" TEXT NOT NULL;
ALTER TABLE "InvoiceRecord" ADD COLUMN "milestoneId" TEXT NOT NULL;
ALTER TABLE "InvoiceRecord" ADD COLUMN "dueAt" TIMESTAMP(3);
ALTER TABLE "InvoiceRecord" ADD COLUMN "remark" TEXT;
ALTER TABLE "InvoiceRecord" ADD COLUMN "createdById" TEXT;
CREATE UNIQUE INDEX "InvoiceRecord_milestoneId_key" ON "InvoiceRecord"("milestoneId");
ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "PaymentMilestone"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- Project hour rate and acceptance timestamps
ALTER TABLE "Project" ADD COLUMN "hourRate" DECIMAL(18,2) NOT NULL DEFAULT 200;
ALTER TABLE "Project" ADD COLUMN "internalAcceptedAt" TIMESTAMP(3);
ALTER TABLE "Project" ADD COLUMN "customerAcceptedAt" TIMESTAMP(3);
-- ProjectTask WBS
ALTER TABLE "ProjectTask" ADD COLUMN "departmentId" TEXT;
ALTER TABLE "ProjectTask" ADD COLUMN "assigneeId" TEXT;
ALTER TABLE "ProjectTask" ADD COLUMN "sortNo" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "ProjectTask" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX "ProjectTask_projectId_idx" ON "ProjectTask"("projectId");
ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Timesheet
ALTER TABLE "Timesheet" ADD COLUMN "taskId" TEXT;
ALTER TABLE "Timesheet" ADD COLUMN "costAmount" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Timesheet" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX "Timesheet_projectId_idx" ON "Timesheet"("projectId");
CREATE INDEX "Timesheet_userId_idx" ON "Timesheet"("userId");
ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "ProjectTask"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Expense budget category (travel / purchase)
ALTER TABLE "ExpenseClaim" ADD COLUMN "budgetCategory" TEXT;
-- ProjectBudget
CREATE TABLE "ProjectBudget" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"category" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
CONSTRAINT "ProjectBudget_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProjectBudget_projectId_category_key" ON "ProjectBudget"("projectId", "category");
ALTER TABLE "ProjectBudget" ADD CONSTRAINT "ProjectBudget_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ProjectAcceptance
CREATE TABLE "ProjectAcceptance" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"result" TEXT NOT NULL,
"comment" TEXT,
"actorId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectAcceptance_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProjectAcceptance_projectId_idx" ON "ProjectAcceptance"("projectId");
ALTER TABLE "ProjectAcceptance" ADD CONSTRAINT "ProjectAcceptance_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectAcceptance" ADD CONSTRAINT "ProjectAcceptance_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- SealRequest: actual return and decision
ALTER TABLE "SealRequest" ADD COLUMN "returnedAt" TIMESTAMP(3);
ALTER TABLE "SealRequest" ADD COLUMN "decidedAt" TIMESTAMP(3);
ALTER TABLE "SealRequest" ADD COLUMN "decidedById" TEXT;
ALTER TABLE "SealRequest" ADD COLUMN "rejectComment" TEXT;
ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CredentialBorrow: link to person, qualification, bid
ALTER TABLE "CredentialBorrow" ADD COLUMN "borrowerId" TEXT;
ALTER TABLE "CredentialBorrow" ADD COLUMN "qualificationId" TEXT;
ALTER TABLE "CredentialBorrow" ADD COLUMN "bidCaseId" TEXT;
ALTER TABLE "CredentialBorrow" ADD COLUMN "remark" TEXT;
ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_borrowerId_fkey" FOREIGN KEY ("borrowerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_qualificationId_fkey" FOREIGN KEY ("qualificationId") REFERENCES "Qualification"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,134 @@
-- Employee: salary, reporting line, lifecycle dates, compensatory leave
ALTER TABLE "Employee" ADD COLUMN "internStartAt" TIMESTAMP(3);
ALTER TABLE "Employee" ADD COLUMN "probationEndAt" TIMESTAMP(3);
ALTER TABLE "Employee" ADD COLUMN "leftAt" TIMESTAMP(3);
ALTER TABLE "Employee" ADD COLUMN "managerId" TEXT;
ALTER TABLE "Employee" ADD COLUMN "baseSalary" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Employee" ADD COLUMN "perfBase" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Employee" ADD COLUMN "socialBase" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Employee" ADD COLUMN "compLeaveHours" DECIMAL(10,2) NOT NULL DEFAULT 0;
CREATE INDEX "Employee_departmentId_idx" ON "Employee"("departmentId");
CREATE INDEX "Employee_managerId_idx" ON "Employee"("managerId");
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
UPDATE "Employee" SET "baseSalary" = 8000, "perfBase" = 0, "socialBase" = 8000 WHERE "employeeNo" = 'E000';
UPDATE "Employee" SET "baseSalary" = 25000, "perfBase" = 10000, "socialBase" = 20000 WHERE "employeeNo" = 'E001';
UPDATE "Employee" SET "baseSalary" = 18000, "perfBase" = 8000, "socialBase" = 18000 WHERE "employeeNo" = 'E010';
UPDATE "Employee" SET "baseSalary" = 9000, "perfBase" = 2500, "socialBase" = 9000 WHERE "employeeNo" = 'E011';
UPDATE "Employee" SET "baseSalary" = 15000, "perfBase" = 5000, "socialBase" = 15000 WHERE "employeeNo" IN ('E020','E021','E022','E023');
UPDATE "Employee" SET "baseSalary" = 10000, "perfBase" = 2000, "socialBase" = 10000 WHERE "employeeNo" = 'E030';
UPDATE "Employee" SET "baseSalary" = 9000, "perfBase" = 2000, "socialBase" = 9000 WHERE "employeeNo" = 'E040';
UPDATE "Employee" SET "baseSalary" = 10000, "perfBase" = 3000, "socialBase" = 10000 WHERE "employeeNo" = 'E100';
UPDATE "Employee" AS e
SET "managerId" = d.id
FROM "Employee" AS d
WHERE e."employeeNo" = 'E100' AND d."employeeNo" = 'E023';
-- CreateTable
CREATE TABLE "EmploymentEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT,
"fromDeptId" TEXT,
"toDeptId" TEXT,
"effectiveAt" TIMESTAMP(3) NOT NULL,
"comment" TEXT,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmploymentEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "EmploymentEvent_employeeId_idx" ON "EmploymentEvent"("employeeId");
CREATE INDEX "EmploymentEvent_effectiveAt_idx" ON "EmploymentEvent"("effectiveAt");
ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_fromDeptId_fkey" FOREIGN KEY ("fromDeptId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_toDeptId_fkey" FOREIGN KEY ("toDeptId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "AttendanceMonth" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"yearMonth" TEXT NOT NULL,
"workDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"leaveDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"sickDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"overtimeHours" DECIMAL(6,2) NOT NULL DEFAULT 0,
"compLeaveDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"remark" TEXT,
"importedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"importedById" TEXT,
CONSTRAINT "AttendanceMonth_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AttendanceMonth_employeeId_yearMonth_key" ON "AttendanceMonth"("employeeId", "yearMonth");
CREATE INDEX "AttendanceMonth_yearMonth_idx" ON "AttendanceMonth"("yearMonth");
ALTER TABLE "AttendanceMonth" ADD CONSTRAINT "AttendanceMonth_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AttendanceMonth" ADD CONSTRAINT "AttendanceMonth_importedById_fkey" FOREIGN KEY ("importedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "PayrollRun" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"yearMonth" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmedAt" TIMESTAMP(3),
CONSTRAINT "PayrollRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PayrollRun_tenantId_yearMonth_key" ON "PayrollRun"("tenantId", "yearMonth");
ALTER TABLE "PayrollRun" ADD CONSTRAINT "PayrollRun_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "PayrollItem" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"prorationFactor" DECIMAL(8,6) NOT NULL DEFAULT 1,
"scheduledDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"dailyRate" DECIMAL(18,2) NOT NULL DEFAULT 0,
"basePay" DECIMAL(18,2) NOT NULL DEFAULT 0,
"performancePay" DECIMAL(18,2) NOT NULL DEFAULT 0,
"performanceRatio" DECIMAL(5,2) NOT NULL DEFAULT 100,
"leaveDeduct" DECIMAL(18,2) NOT NULL DEFAULT 0,
"sickDeduct" DECIMAL(18,2) NOT NULL DEFAULT 0,
"socialPersonal" DECIMAL(18,2) NOT NULL DEFAULT 0,
"housingFund" DECIMAL(18,2) NOT NULL DEFAULT 0,
"tax" DECIMAL(18,2) NOT NULL DEFAULT 0,
"grossPay" DECIMAL(18,2) NOT NULL DEFAULT 0,
"netPay" DECIMAL(18,2) NOT NULL DEFAULT 0,
"overtimeHours" DECIMAL(6,2) NOT NULL DEFAULT 0,
"compLeaveUsedDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"compLeaveHours" DECIMAL(10,2) NOT NULL DEFAULT 0,
CONSTRAINT "PayrollItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PayrollItem_runId_employeeId_key" ON "PayrollItem"("runId", "employeeId");
ALTER TABLE "PayrollItem" ADD CONSTRAINT "PayrollItem_runId_fkey" FOREIGN KEY ("runId") REFERENCES "PayrollRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PayrollItem" ADD CONSTRAINT "PayrollItem_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Directors only see 绩效发放 under 组织人事, not the full HR module
INSERT INTO "RolePermission" ("roleId", "permissionId")
SELECT r.id, p.id
FROM "Role" r
CROSS JOIN "Permission" p
WHERE r.code IN ('tech_director', 'biz_director')
AND p.code = 'hr:performance'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,98 @@
ALTER TABLE "Role" ADD COLUMN "dataScope" TEXT NOT NULL DEFAULT 'ALL';
ALTER TABLE "WorkReport" ADD COLUMN "periodType" TEXT NOT NULL DEFAULT 'DAY';
ALTER TABLE "WorkReport" ADD COLUMN "periodStart" TIMESTAMP(3);
ALTER TABLE "WorkReport" ADD COLUMN "hours" DECIMAL(8,2) NOT NULL DEFAULT 0;
DELETE FROM "WorkReport" WHERE "authorId" IS NOT NULL AND "authorId" NOT IN (SELECT "id" FROM "User");
ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "WorkReport_authorId_idx" ON "WorkReport"("authorId");
ALTER TABLE "PurchaseRequest" ADD COLUMN "remark" TEXT;
ALTER TABLE "PurchaseRequest" ADD COLUMN "applicantId" TEXT;
ALTER TABLE "PurchaseRequest" ADD COLUMN "decidedById" TEXT;
ALTER TABLE "PurchaseRequest" ADD COLUMN "decidedAt" TIMESTAMP(3);
UPDATE "PurchaseRequest" SET "projectId" = NULL WHERE "projectId" IS NOT NULL AND "projectId" NOT IN (SELECT "id" FROM "Project");
ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssetOccupancy" ADD COLUMN "holderId" TEXT;
ALTER TABLE "AssetOccupancy" ADD COLUMN "projectId" TEXT;
ALTER TABLE "AssetOccupancy" ADD COLUMN "dueAt" TIMESTAMP(3);
ALTER TABLE "AssetOccupancy" ADD COLUMN "returnedAt" TIMESTAMP(3);
UPDATE "AssetOccupancy" SET "projectId" = NULL WHERE "projectId" IS NOT NULL AND "projectId" NOT IN (SELECT "id" FROM "Project");
ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_holderId_fkey" FOREIGN KEY ("holderId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OperationLog" ADD COLUMN "username" TEXT;
ALTER TABLE "OperationLog" ADD COLUMN "detail" TEXT;
ALTER TABLE "OperationLog" ADD COLUMN "success" BOOLEAN NOT NULL DEFAULT true;
UPDATE "OperationLog" SET "userId" = NULL WHERE "userId" IS NOT NULL AND "userId" NOT IN (SELECT "id" FROM "User");
ALTER TABLE "OperationLog" ADD CONSTRAINT "OperationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "OperationLog_createdAt_idx" ON "OperationLog"("createdAt");
CREATE INDEX "OperationLog_action_idx" ON "OperationLog"("action");
CREATE TABLE "NumberRule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"padLength" INTEGER NOT NULL DEFAULT 5,
CONSTRAINT "NumberRule_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "NumberRule_tenantId_code_key" ON "NumberRule"("tenantId", "code");
INSERT INTO "NumberRule" ("id", "code", "name", "prefix", "padLength") VALUES
('nr-bid', 'BID', '投标编号', 'BID', 5),
('nr-ht', 'HT', '合同编号', 'HT', 5),
('nr-prj', 'PRJ', '项目编号', 'PRJ', 5);
CREATE TABLE "SealItem" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"keeper" TEXT NOT NULL DEFAULT '行政',
"status" TEXT NOT NULL DEFAULT 'IN_STOCK',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SealItem_pkey" PRIMARY KEY ("id")
);
INSERT INTO "SealItem" ("id", "name", "keeper", "status") VALUES
('seal-official', '公章', '行政', 'IN_STOCK'),
('seal-legal', '法人章', '行政', 'IN_STOCK');
CREATE TABLE "ProjectChange" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"projectId" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"amountDelta" DECIMAL(18,2) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"decidedAt" TIMESTAMP(3),
CONSTRAINT "ProjectChange_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProjectChange_projectId_idx" ON "ProjectChange"("projectId");
ALTER TABLE "ProjectChange" ADD CONSTRAINT "ProjectChange_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectChange" ADD CONSTRAINT "ProjectChange_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE TABLE "PaymentRecord" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"payNo" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"refId" TEXT,
"amount" DECIMAL(18,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'TODO',
"remark" TEXT,
"paidAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PaymentRecord_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PaymentRecord_tenantId_payNo_key" ON "PaymentRecord"("tenantId", "payNo");
ALTER TABLE "PaymentRecord" ADD CONSTRAINT "PaymentRecord_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
ALTER TABLE "WorkReport" ADD COLUMN "toUserId" TEXT;
ALTER TABLE "WorkReport" ADD COLUMN "ccUserId" TEXT;
ALTER TABLE "WorkReport" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'DRAFT';
ALTER TABLE "WorkReport" ADD COLUMN "submittedAt" TIMESTAMP(3);
ALTER TABLE "WorkReport" ALTER COLUMN "content" SET DEFAULT '';
UPDATE "WorkReport" SET "toUserId" = NULL WHERE "toUserId" IS NOT NULL AND "toUserId" NOT IN (SELECT "id" FROM "User");
UPDATE "WorkReport" SET "ccUserId" = NULL WHERE "ccUserId" IS NOT NULL AND "ccUserId" NOT IN (SELECT "id" FROM "User");
ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_ccUserId_fkey" FOREIGN KEY ("ccUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "WorkReport_toUserId_idx" ON "WorkReport"("toUserId");
CREATE INDEX "WorkReport_ccUserId_idx" ON "WorkReport"("ccUserId");
@@ -0,0 +1,29 @@
ALTER TABLE "ExpenseClaim" ADD COLUMN IF NOT EXISTS "expenseCategory" TEXT;
CREATE TABLE IF NOT EXISTS "FileAsset" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"bizType" TEXT NOT NULL,
"bizId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"storageKey" TEXT NOT NULL,
"uploadedById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileAsset_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "FileAsset_bizType_bizId_idx" ON "FileAsset"("bizType", "bizId");
CREATE INDEX IF NOT EXISTS "FileAsset_uploadedById_idx" ON "FileAsset"("uploadedById");
INSERT INTO "DictItem" ("id", "tenantId", "dictType", "label", "value", "sortNo")
VALUES
('e1c10000-0000-4000-8000-000000000001', '1', 'expenseCategory', '交通', '交通', 1),
('e1c10000-0000-4000-8000-000000000002', '1', 'expenseCategory', '餐饮', '餐饮', 2),
('e1c10000-0000-4000-8000-000000000003', '1', 'expenseCategory', '住宿', '住宿', 3),
('e1c10000-0000-4000-8000-000000000004', '1', 'expenseCategory', '办公', '办公', 4),
('e1c10000-0000-4000-8000-000000000005', '1', 'expenseCategory', '招待', '招待', 5),
('e1c10000-0000-4000-8000-000000000006', '1', 'expenseCategory', '其他', '其他', 6)
ON CONFLICT ("tenantId", "dictType", "value") DO NOTHING;
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "SmsSendLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"phones" TEXT NOT NULL,
"signName" TEXT NOT NULL,
"templateCode" TEXT NOT NULL,
"templateParam" TEXT,
"bizId" TEXT,
"code" TEXT NOT NULL,
"message" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsSendLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SmsSendLog_createdAt_idx" ON "SmsSendLog"("createdAt");
@@ -0,0 +1,67 @@
-- Employee archive fields aligned with personnel files
ALTER TABLE "Employee" ADD COLUMN "gender" TEXT;
ALTER TABLE "Employee" ADD COLUMN "email" TEXT;
ALTER TABLE "Employee" ADD COLUMN "mobile" TEXT;
ALTER TABLE "Employee" ADD COLUMN "idNo" TEXT;
ALTER TABLE "Employee" ADD COLUMN "education" TEXT;
ALTER TABLE "Employee" ADD COLUMN "bankName" TEXT;
ALTER TABLE "Employee" ADD COLUMN "bankAccount" TEXT;
ALTER TABLE "Employee" ADD COLUMN "remark" TEXT;
CREATE TABLE "HrSetting" (
"id" TEXT NOT NULL DEFAULT 'default',
"tenantId" TEXT NOT NULL DEFAULT '1',
"idMask" BOOLEAN NOT NULL DEFAULT true,
"autoAccount" BOOLEAN NOT NULL DEFAULT false,
"probationMonths" INTEGER NOT NULL DEFAULT 3,
"defaultRest" TEXT NOT NULL DEFAULT 'SINGLE',
"workStart" TEXT NOT NULL DEFAULT '09:00',
"workEnd" TEXT NOT NULL DEFAULT '18:00',
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HrSetting_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HrSetting_tenantId_key" ON "HrSetting"("tenantId");
INSERT INTO "HrSetting" ("id", "tenantId", "idMask", "autoAccount", "probationMonths", "defaultRest", "workStart", "workEnd", "updatedAt")
VALUES ('default', '1', true, false, 3, 'SINGLE', '09:00', '18:00', CURRENT_TIMESTAMP);
CREATE TABLE "AttendanceRule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"restSchedule" TEXT NOT NULL,
"workStart" TEXT NOT NULL,
"workEnd" TEXT NOT NULL,
"lateGraceMinutes" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"sortNo" INTEGER NOT NULL DEFAULT 0,
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AttendanceRule_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AttendanceRule_tenantId_name_key" ON "AttendanceRule"("tenantId", "name");
INSERT INTO "AttendanceRule" ("id", "tenantId", "name", "restSchedule", "workStart", "workEnd", "lateGraceMinutes", "enabled", "sortNo", "remark", "createdAt", "updatedAt")
VALUES
('att-rule-single-000000000001', '1', '单休班次', 'SINGLE', '09:00', '18:00', 0, true, 1, '每周休一天,薪酬按 26.09 天核算', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('att-rule-double-000000000001', '1', '双休班次', 'DOUBLE', '09:00', '18:00', 0, true, 2, '每周休两天,薪酬按 21.75 天核算', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
INSERT INTO "DictItem" ("id", "tenantId", "dictType", "label", "value", "sortNo")
SELECT d."id", '1', d."dictType", d."label", d."value", d."sortNo"
FROM (VALUES
('dict-gender-male-00000000001', 'gender', '', 'MALE', 1),
('dict-gender-female-000000001', 'gender', '', 'FEMALE', 2),
('dict-edu-high-0000000000001', 'education', '高中及以下', 'HIGH_SCHOOL', 1),
('dict-edu-college-00000000001', 'education', '专科', 'COLLEGE', 2),
('dict-edu-bachelor-0000000001', 'education', '本科', 'BACHELOR', 3),
('dict-edu-master-000000000001', 'education', '硕士', 'MASTER', 4),
('dict-edu-doctor-000000000001', 'education', '博士', 'DOCTOR', 5)
) AS d("id", "dictType", "label", "value", "sortNo")
WHERE NOT EXISTS (
SELECT 1 FROM "DictItem" x WHERE x."tenantId" = '1' AND x."dictType" = d."dictType" AND x."value" = d."value"
);
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Contract" ADD COLUMN "partyA" TEXT;
ALTER TABLE "Contract" ADD COLUMN "partyB" TEXT;
@@ -0,0 +1,48 @@
-- CreateTable
CREATE TABLE "AttendancePunch" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"punchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"remark" TEXT,
CONSTRAINT "AttendancePunch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeaveRequest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"startAt" TIMESTAMP(3) NOT NULL,
"endAt" TIMESTAMP(3) NOT NULL,
"days" DECIMAL(6,2) NOT NULL,
"reason" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeaveRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AttendancePunch_employeeId_punchedAt_idx" ON "AttendancePunch"("employeeId", "punchedAt");
-- CreateIndex
CREATE INDEX "LeaveRequest_employeeId_idx" ON "LeaveRequest"("employeeId");
-- CreateIndex
CREATE INDEX "LeaveRequest_status_idx" ON "LeaveRequest"("status");
-- AddForeignKey
ALTER TABLE "AttendancePunch" ADD CONSTRAINT "AttendancePunch_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "LaborContract" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"contractNo" TEXT,
"startAt" TIMESTAMP(3),
"endAt" TIMESTAMP(3),
"remark" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LaborContract_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LaborContract_employeeId_idx" ON "LaborContract"("employeeId");
-- CreateIndex
CREATE INDEX "LaborContract_tenantId_createdAt_idx" ON "LaborContract"("tenantId", "createdAt");
-- AddForeignKey
ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,70 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "mustChangePassword" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "profileCompletedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "Contract" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'OTHER';
UPDATE "Contract" SET "kind" = 'TENDER' WHERE "bidCaseId" IS NOT NULL;
-- AlterTable
ALTER TABLE "BidCase" ADD COLUMN IF NOT EXISTS "needSurvey" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "SealItem" ADD COLUMN IF NOT EXISTS "sealType" TEXT NOT NULL DEFAULT '公章';
ALTER TABLE "SealItem" ADD COLUMN IF NOT EXISTS "legalEntityId" TEXT;
-- CreateTable
CREATE TABLE IF NOT EXISTS "WorkAssignment" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"content" TEXT NOT NULL DEFAULT '',
"assigneeId" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'OPEN',
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "RegisteredAsset" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"kind" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"spec" TEXT,
"keeper" TEXT,
"location" TEXT,
"amount" DECIMAL(18,2),
"remark" TEXT,
"legalEntityId" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisteredAsset_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "WorkAssignment_assigneeId_idx" ON "WorkAssignment"("assigneeId");
CREATE INDEX IF NOT EXISTS "WorkAssignment_createdById_idx" ON "WorkAssignment"("createdById");
CREATE INDEX IF NOT EXISTS "RegisteredAsset_kind_idx" ON "RegisteredAsset"("kind");
CREATE INDEX IF NOT EXISTS "RegisteredAsset_legalEntityId_idx" ON "RegisteredAsset"("legalEntityId");
ALTER TABLE "WorkAssignment" DROP CONSTRAINT IF EXISTS "WorkAssignment_assigneeId_fkey";
ALTER TABLE "WorkAssignment" ADD CONSTRAINT "WorkAssignment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "WorkAssignment" DROP CONSTRAINT IF EXISTS "WorkAssignment_createdById_fkey";
ALTER TABLE "WorkAssignment" ADD CONSTRAINT "WorkAssignment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "RegisteredAsset" DROP CONSTRAINT IF EXISTS "RegisteredAsset_legalEntityId_fkey";
ALTER TABLE "RegisteredAsset" ADD CONSTRAINT "RegisteredAsset_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "RegisteredAsset" DROP CONSTRAINT IF EXISTS "RegisteredAsset_createdById_fkey";
ALTER TABLE "RegisteredAsset" ADD CONSTRAINT "RegisteredAsset_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SealItem" DROP CONSTRAINT IF EXISTS "SealItem_legalEntityId_fkey";
ALTER TABLE "SealItem" ADD CONSTRAINT "SealItem_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 系统管理员不强制改密/补档
UPDATE "User" u SET "mustChangePassword" = false, "profileCompletedAt" = CURRENT_TIMESTAMP
WHERE EXISTS (SELECT 1 FROM "UserRole" ur JOIN "Role" r ON r.id = ur."roleId" WHERE ur."userId" = u.id AND r.code = 'admin');
@@ -0,0 +1 @@
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "partyName" TEXT;
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS "ProjectAcceptMaterial" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"remark" TEXT,
"required" BOOLEAN NOT NULL DEFAULT true,
"sortNo" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectAcceptMaterial_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "ProjectAcceptMaterial_projectId_idx" ON "ProjectAcceptMaterial"("projectId");
ALTER TABLE "ProjectAcceptMaterial" DROP CONSTRAINT IF EXISTS "ProjectAcceptMaterial_projectId_fkey";
ALTER TABLE "ProjectAcceptMaterial" ADD CONSTRAINT "ProjectAcceptMaterial_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,51 @@
-- AlterTable
ALTER TABLE "AssetOccupancy" ADD COLUMN IF NOT EXISTS "registeredAssetId" TEXT;
CREATE INDEX IF NOT EXISTS "AssetOccupancy_registeredAssetId_idx" ON "AssetOccupancy"("registeredAssetId");
ALTER TABLE "AssetOccupancy" DROP CONSTRAINT IF EXISTS "AssetOccupancy_registeredAssetId_fkey";
ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_registeredAssetId_fkey" FOREIGN KEY ("registeredAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE IF NOT EXISTS "OfficeApply" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"kind" TEXT NOT NULL,
"title" TEXT NOT NULL,
"reason" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"assetKind" TEXT,
"registeredAssetId" TEXT,
"amount" DECIMAL(18,2),
"destination" TEXT,
"startAt" TIMESTAMP(3),
"endAt" TIMESTAMP(3),
"projectId" TEXT,
"purchaseId" TEXT,
"occupancyId" TEXT,
"applicantId" TEXT NOT NULL,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OfficeApply_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "OfficeApply_applicantId_idx" ON "OfficeApply"("applicantId");
CREATE INDEX IF NOT EXISTS "OfficeApply_status_idx" ON "OfficeApply"("status");
CREATE INDEX IF NOT EXISTS "OfficeApply_kind_idx" ON "OfficeApply"("kind");
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_applicantId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_decidedById_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_registeredAssetId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_registeredAssetId_fkey" FOREIGN KEY ("registeredAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_projectId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_purchaseId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_purchaseId_fkey" FOREIGN KEY ("purchaseId") REFERENCES "PurchaseRequest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_occupancyId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_occupancyId_fkey" FOREIGN KEY ("occupancyId") REFERENCES "AssetOccupancy"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
ALTER TABLE "RegisteredAsset" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "RegisteredAsset" ADD COLUMN IF NOT EXISTS "usage" TEXT NOT NULL DEFAULT 'GENERAL';
ALTER TABLE "OfficeApply" ADD COLUMN IF NOT EXISTS "inboundAssetId" TEXT;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_inboundAssetId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_inboundAssetId_fkey" FOREIGN KEY ("inboundAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE TABLE IF NOT EXISTS "Notice" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PUBLISHED',
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Notice_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "Notice_status_idx" ON "Notice"("status");
CREATE INDEX IF NOT EXISTS "Notice_createdAt_idx" ON "Notice"("createdAt");
ALTER TABLE "Notice" DROP CONSTRAINT IF EXISTS "Notice_createdById_fkey";
ALTER TABLE "Notice" ADD CONSTRAINT "Notice_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,29 @@
ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'ANNOUNCEMENT';
CREATE INDEX IF NOT EXISTS "Notice_kind_idx" ON "Notice"("kind");
ALTER TABLE "SmsSendLog" ADD COLUMN IF NOT EXISTS "eventKey" TEXT;
CREATE INDEX IF NOT EXISTS "SmsSendLog_eventKey_idx" ON "SmsSendLog"("eventKey");
CREATE TABLE IF NOT EXISTS "SmsEventBind" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"eventKey" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"templateCode" TEXT NOT NULL DEFAULT '',
"signName" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsEventBind_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "SmsEventBind_eventKey_key" ON "SmsEventBind"("eventKey");
CREATE TABLE IF NOT EXISTS "SmsOtp" (
"id" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsOtp_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "SmsOtp_phone_purpose_idx" ON "SmsOtp"("phone", "purpose");
@@ -0,0 +1,2 @@
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "projectType" TEXT NOT NULL DEFAULT '';
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "amount" DECIMAL(18,2) NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "audience" TEXT NOT NULL DEFAULT 'ALL';
ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "departmentIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "userIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1 @@
ALTER TABLE "Role" ADD COLUMN IF NOT EXISTS "aclCustom" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS "PushDevice" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"vendor" TEXT NOT NULL,
"regId" TEXT NOT NULL,
"brand" TEXT,
"model" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushDevice_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PushDevice_vendor_regId_key" ON "PushDevice"("vendor", "regId");
CREATE INDEX IF NOT EXISTS "PushDevice_userId_vendor_idx" ON "PushDevice"("userId", "vendor");
ALTER TABLE "PushDevice" ADD CONSTRAINT "PushDevice_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "recalledAt" TIMESTAMP(3);
ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "editedAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "ImMessage_recalledAt_idx" ON "ImMessage"("recalledAt");
@@ -0,0 +1,3 @@
-- 项目筛选编号是人工填写的公告编号,不是系统单号;允许重复公告/分包再次筛选。
DROP INDEX IF EXISTS "BidCase_tenantId_bidNo_key";
CREATE INDEX IF NOT EXISTS "BidCase_tenantId_bidNo_idx" ON "BidCase"("tenantId", "bidNo");
@@ -0,0 +1,26 @@
-- Phase 1.1: 会话级单调递增 seq,用于消息排序、去重与断线补拉
ALTER TABLE "ImConversation" ADD COLUMN IF NOT EXISTS "nextSeq" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "seq" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "ImReadCursor" ADD COLUMN IF NOT EXISTS "lastReadSeq" INTEGER NOT NULL DEFAULT 0;
-- 按 createdAt 回填历史 seq
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "conversationId" ORDER BY "createdAt", id) AS rn
FROM "ImMessage"
)
UPDATE "ImMessage" m SET seq = ranked.rn FROM ranked WHERE m.id = ranked.id;
UPDATE "ImConversation" c
SET "nextSeq" = COALESCE((
SELECT MAX(seq) + 1 FROM "ImMessage" WHERE "conversationId" = c.id
), 1);
UPDATE "ImReadCursor" rc
SET "lastReadSeq" = COALESCE((
SELECT MAX(seq) FROM "ImMessage" m
WHERE m."conversationId" = rc."conversationId" AND m."createdAt" <= rc."lastReadAt"
), 0);
CREATE INDEX IF NOT EXISTS "ImMessage_conversationId_seq_idx" ON "ImMessage"("conversationId", "seq");
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+23
View File
@@ -0,0 +1,23 @@
import * as fs from 'fs';
/** OA 雪花 ID 超过 Number.MAX_SAFE_INTEGERJSON.parse 会丢精度导致报销明细串单。 */
export function parseOaJson<T = Record<string, unknown>>(file: string): T {
const text = fs.readFileSync(file, 'utf8');
const safe = text.replace(
/"(id|reimbursement_id|project_id|employee_id|payer_id|created_by|updated_by|department_id|cc_user_id|offset_advance_id|current_approver_id|user_id|post_id|dept_id|apply_id|tender_id|contract_id)"\s*:\s*(-?\d+)/g,
'"$1":"$2"',
);
return JSON.parse(safe) as T;
}
export function mapExpenseLineKind(itemType: string, remark = '') {
const t = `${itemType}${remark}`;
if (t.includes('机票') || t.includes('航班')) return 'AIR';
if (t.includes('高铁')) return 'HSR';
if (t.includes('火车') || t.includes('卧铺')) return 'TRAIN';
if (t.includes('出租') || t.includes('打车') || t.includes('交通') || t.includes('高速')) return 'TAXI';
if (t.includes('住宿') || t.includes('酒店')) return 'HOTEL';
if (t.includes('餐')) return 'MEAL';
if (t.includes('办公') || t.includes('胶装')) return 'OFFICE';
return 'OTHER';
}
@@ -0,0 +1,38 @@
\set ON_ERROR_STOP on
BEGIN;
DO $$
DECLARE
orphan_count integer;
mirrored_count integer;
BEGIN
SELECT count(*) INTO orphan_count
FROM "FileAsset" file
WHERE file."bizType" LIKE 'CONTRACT%'
AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = file."bizId");
SELECT count(*) INTO mirrored_count
FROM "FileAsset" orphan
WHERE orphan."bizType" LIKE 'CONTRACT%'
AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = orphan."bizId")
AND EXISTS (
SELECT 1
FROM "FileAsset" current_file
JOIN "Contract" current_contract ON current_contract.id = current_file."bizId"
WHERE current_file."bizType" = orphan."bizType"
AND current_file."fileName" = orphan."fileName"
AND current_file.size = orphan.size
);
IF orphan_count <> 29 OR mirrored_count <> orphan_count THEN
RAISE EXCEPTION 'Unexpected orphan contract files: orphan %, mirrored %',
orphan_count, mirrored_count;
END IF;
END $$;
DELETE FROM "FileAsset" file
WHERE file."bizType" LIKE 'CONTRACT%'
AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = file."bizId");
COMMIT;
@@ -0,0 +1,111 @@
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE expense_dedup_map (
old_id text PRIMARY KEY,
new_id text NOT NULL UNIQUE
) ON COMMIT DROP;
INSERT INTO expense_dedup_map (old_id, new_id)
SELECT old_claim.id, formal_claim.id
FROM "ExpenseClaim" old_claim
JOIN "ExpenseClaim" formal_claim
ON formal_claim."claimNo" ~ '^BX-[0-9]{8}-[0-9]{3}$'
AND formal_claim."applicantId" = old_claim."applicantId"
AND formal_claim.amount = old_claim.amount
AND formal_claim."projectId" IS NOT DISTINCT FROM old_claim."projectId"
AND formal_claim."bidCaseId" IS NOT DISTINCT FROM old_claim."bidCaseId"
WHERE old_claim."createdAt" >= TIMESTAMP '2026-08-29 11:00:30'
AND old_claim."createdAt" < TIMESTAMP '2026-08-29 11:00:31';
INSERT INTO expense_dedup_map (old_id, new_id)
SELECT old_claim.id, formal_claim.id
FROM "ExpenseClaim" old_claim
JOIN "ExpenseClaim" formal_claim
ON formal_claim."claimNo" = CASE old_claim."claimNo"
WHEN 'BX-2026-00043' THEN 'BX-20260617-004'
WHEN 'BX-2026-00044' THEN 'BX-20260617-005'
END
WHERE old_claim."claimNo" IN ('BX-2026-00043', 'BX-2026-00044');
DO $$
BEGIN
IF (SELECT count(*) FROM expense_dedup_map) <> 83 THEN
RAISE EXCEPTION 'Expense dedup mapping is incomplete: expected 83, got %',
(SELECT count(*) FROM expense_dedup_map);
END IF;
END $$;
-- FileAsset is intentionally reassigned before duplicate claims are removed.
UPDATE "FileAsset" file
SET "bizId" = mapping.new_id
FROM expense_dedup_map mapping
WHERE file."bizType" = 'EXPENSE'
AND file."bizId" = mapping.old_id;
DELETE FROM "ApprovalTask" task
USING expense_dedup_map mapping
WHERE task."bizId" = mapping.old_id;
DELETE FROM "TodoTask" task
USING expense_dedup_map mapping
WHERE task."bizId" = mapping.old_id;
DELETE FROM "ExpenseClaim" claim
USING expense_dedup_map mapping
WHERE claim.id = mapping.old_id;
CREATE TEMP TABLE loan_dedup_map (
old_id text PRIMARY KEY,
new_id text NOT NULL UNIQUE,
new_status text NOT NULL
) ON COMMIT DROP;
INSERT INTO loan_dedup_map (old_id, new_id, new_status)
SELECT old_loan.id, formal_loan.id, formal_loan.status
FROM "LoanRecord" old_loan
JOIN "LoanRecord" formal_loan
ON formal_loan."loanNo" ~ '^JZ-[0-9]{8}-[0-9]{3}$'
AND formal_loan."applicantId" = old_loan."applicantId"
AND formal_loan.amount = old_loan.amount
AND formal_loan."projectId" IS NOT DISTINCT FROM old_loan."projectId"
AND formal_loan.purpose = old_loan.purpose
WHERE old_loan."loanNo" ~ '^JK-2026-[0-9]{5}$'
AND old_loan."createdAt" >= TIMESTAMP '2026-08-29 11:00:30'
AND old_loan."createdAt" < TIMESTAMP '2026-08-29 11:00:31';
DO $$
BEGIN
IF (SELECT count(*) FROM loan_dedup_map) <> 9 THEN
RAISE EXCEPTION 'Loan dedup mapping is incomplete: expected 9, got %',
(SELECT count(*) FROM loan_dedup_map);
END IF;
END $$;
-- Keep approval tasks only when the surviving formal loan is still pending.
UPDATE "ApprovalTask" task
SET "bizId" = mapping.new_id
FROM loan_dedup_map mapping
WHERE task."bizId" = mapping.old_id
AND mapping.new_status = 'PENDING';
DELETE FROM "ApprovalTask" task
USING loan_dedup_map mapping
WHERE task."bizId" = mapping.old_id;
DELETE FROM "TodoTask" task
USING loan_dedup_map mapping
WHERE task."bizId" = mapping.old_id;
UPDATE "FileAsset" file
SET "bizId" = mapping.new_id
FROM loan_dedup_map mapping
WHERE file."bizType" = 'LOAN'
AND file."bizId" = mapping.old_id;
DELETE FROM "LoanRecord" loan
USING loan_dedup_map mapping
WHERE loan.id = mapping.old_id;
COMMIT;
@@ -0,0 +1,61 @@
\set ON_ERROR_STOP on
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "ExpenseClaim"
WHERE "tenantId" = '1' AND "claimNo" = 'BX-20260809-002'
) THEN
RAISE EXCEPTION 'Expense BX-20260809-002 already exists';
END IF;
END $$;
WITH restored AS (
INSERT INTO "ExpenseClaim" (
id, "tenantId", "claimNo", "applicantId", "costType",
"budgetCategory", "projectId", "expenseCategory", purpose,
amount, status, "submittedAt", "createdAt"
)
SELECT
gen_random_uuid()::text,
'1',
'BX-20260809-002',
applicant.id,
'PROJECT',
'TRAVEL',
project.id,
'办公',
'日常办公(江苏风影)',
689.08,
'DRAFT',
TIMESTAMP '2026-08-09 21:39:30',
TIMESTAMP '2026-08-09 21:39:30'
FROM "User" applicant
CROSS JOIN "Project" project
WHERE applicant."displayName" = '戴永康'
AND project."projectNo" = 'XM-20260609-002'
RETURNING id
)
INSERT INTO "ExpenseLine" (
id, "tenantId", "claimId", kind, amount, remark, "sortNo", "createdAt"
)
SELECT gen_random_uuid()::text, '1', restored.id, line.kind, line.amount,
line.remark, line.sort_no, TIMESTAMP '2026-08-09 21:39:30'
FROM restored
CROSS JOIN (
VALUES
('OFFICE', 122.65::numeric, 'OA平台短信通知费用', 1),
('OFFICE', 566.43::numeric, 'OA云服务器及流量费用', 2)
) AS line(kind, amount, remark, sort_no);
DO $$
BEGIN
IF (SELECT count(*) FROM "ExpenseClaim" WHERE "claimNo" = 'BX-20260809-002') <> 1
OR (SELECT count(*) FROM "ExpenseLine" line JOIN "ExpenseClaim" claim ON claim.id = line."claimId" WHERE claim."claimNo" = 'BX-20260809-002') <> 2 THEN
RAISE EXCEPTION 'Expense BX-20260809-002 restore verification failed';
END IF;
END $$;
COMMIT;
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
import { MENUS, type MenuNode } from './menus';
const prisma = new PrismaClient();
async function main() {
const passwordHash = await argon2.hash('Demo@123456');
const adminHash = await argon2.hash('Admin@123456');
await prisma.$transaction(async (tx) => {
await tx.operationLog.deleteMany();
await tx.paymentRecord.deleteMany();
await tx.projectChange.deleteMany();
await tx.sealItem.deleteMany();
await tx.numberRule.deleteMany();
await tx.todoTask.deleteMany();
await tx.approvalTask.deleteMany();
await tx.workReport.deleteMany();
await tx.payrollItem.deleteMany();
await tx.payrollRun.deleteMany();
await tx.attendanceMonth.deleteMany();
await tx.employmentEvent.deleteMany();
await tx.expenseClaim.deleteMany();
await tx.loanRecord.deleteMany();
await tx.bondRecord.deleteMany();
await tx.invoiceRecord.deleteMany();
await tx.timesheet.deleteMany();
await tx.projectAcceptance.deleteMany();
await tx.projectBudget.deleteMany();
await tx.projectTask.deleteMany();
await tx.purchaseRequest.deleteMany();
await tx.assetOccupancy.deleteMany();
await tx.contractReviewer.deleteMany();
await tx.contractActionLog.deleteMany();
await tx.paymentMilestone.deleteMany();
await tx.project.deleteMany();
await tx.sealRequest.deleteMany();
await tx.contract.deleteMany();
await tx.bidDocVersion.deleteMany();
await tx.bidAssignee.deleteMany();
await tx.bidActionLog.deleteMany();
await tx.bidReviewer.deleteMany();
await tx.credentialBorrow.deleteMany();
await tx.bidCase.deleteMany();
await tx.partyContact.deleteMany();
await tx.qualification.deleteMany();
await tx.userRole.deleteMany();
await tx.rolePermission.deleteMany();
await tx.refreshToken.deleteMany();
await tx.user.deleteMany();
await tx.employee.deleteMany();
await tx.department.deleteMany();
await tx.permission.deleteMany();
await tx.role.deleteMany();
await tx.dictItem.deleteMany();
await tx.attendanceRule.deleteMany();
await tx.hrSetting.deleteMany();
await tx.legalEntity.deleteMany();
await tx.businessParty.deleteMany();
const root = await tx.department.create({
data: {
name: '演示科技有限公司',
code: 'ROOT',
deptType: 'ADMIN',
sortNo: 0,
},
});
const depts = await Promise.all(
[
['商务部', 'BIZ', 'BUSINESS', null, 10],
['三维部门', 'TECH_3D', 'TECH', 'CAT_3D', 20],
['摄制部门', 'TECH_FILM', 'TECH', 'CAT_FILM', 30],
['物资部门', 'TECH_MATERIAL', 'TECH', 'CAT_MATERIAL', 40],
['软件研发部门', 'TECH_SOFTWARE', 'TECH', 'CAT_SOFTWARE', 50],
['财务部', 'FIN', 'FINANCE', null, 60],
['人事行政', 'HR', 'HR', null, 70],
].map(([name, code, deptType, categoryCode, sortNo]) =>
tx.department.create({
data: {
parentId: root.id,
name: name as string,
code: code as string,
deptType: deptType as string,
categoryCode: categoryCode as string | null,
sortNo: sortNo as number,
},
}),
),
);
const byCode = Object.fromEntries(depts.map((d) => [d.code, d]));
const permIds: Record<string, string> = {};
const writeMenus = async (nodes: MenuNode[], parentId: string | null, root = true) => {
for (let i = 0; i < nodes.length; i++) {
const menu = nodes[i];
const row = await tx.permission.create({
data: {
parentId,
name: menu.name,
code: menu.code,
permType: 'MENU',
path: menu.path,
icon: menu.icon,
sortNo: root ? (i + 1) * 10 : i + 1,
},
});
permIds[menu.code] = row.id;
if (menu.children?.length) await writeMenus(menu.children, row.id, false);
}
};
await writeMenus(MENUS, null);
const roles = {
admin: await tx.role.create({ data: { name: '系统管理员', code: 'admin', dataScope: 'ALL' } }),
owner: await tx.role.create({ data: { name: '企业负责人', code: 'owner', dataScope: 'ALL' } }),
biz_director: await tx.role.create({ data: { name: '商务总监', code: 'biz_director', dataScope: 'DEPT' } }),
biz_staff: await tx.role.create({ data: { name: '商务专员', code: 'biz_staff', dataScope: 'SELF' } }),
tech_director: await tx.role.create({ data: { name: '技术总监', code: 'tech_director', dataScope: 'DEPT' } }),
finance: await tx.role.create({ data: { name: '财务人员', code: 'finance', dataScope: 'ALL' } }),
hr: await tx.role.create({ data: { name: '人事', code: 'hr', dataScope: 'ALL' } }),
employee: await tx.role.create({ data: { name: '员工', code: 'employee', dataScope: 'SELF' } }),
};
const allPerms = await tx.permission.findMany();
const grant = async (roleId: string, codes: string[]) => {
const selected =
codes[0] === '*'
? allPerms
: allPerms.filter((p) => codes.some((c) => p.code === c || p.code.startsWith(c)));
await tx.rolePermission.createMany({
data: selected.map((p) => ({ roleId, permissionId: p.id })),
skipDuplicates: true,
});
};
await grant(roles.admin.id, ['*']);
await grant(roles.owner.id, ['*']);
await grant(roles.biz_director.id, ['office', 'party', 'seal', 'bid', 'contract', 'finance', 'report', 'hr:performance']);
await grant(roles.biz_staff.id, ['office', 'party', 'seal', 'bid', 'contract']);
await grant(roles.tech_director.id, ['office', 'bid', 'project', 'report', 'hr:performance']);
await grant(roles.finance.id, ['office', 'finance', 'contract', 'report', 'bid:result']);
await grant(roles.hr.id, ['office', 'hr', 'system:hr', 'system:org', 'system:attendance-rules']);
await grant(roles.employee.id, ['office', 'project:timesheets']);
const mkEmp = (
name: string,
no: string,
deptCode: string,
title: string,
rest: string,
baseSalary: number,
perfBase: number,
socialBase: number,
) =>
tx.employee.create({
data: {
name,
employeeNo: no,
departmentId: byCode[deptCode].id,
title,
restSchedule: rest,
employmentStatus: 'REGULAR',
hiredAt: new Date('2022-01-01'),
baseSalary,
perfBase,
socialBase,
},
});
const emps = {
admin: await mkEmp('系统管理员', 'E000', 'HR', '管理员', 'DOUBLE', 8000, 0, 8000),
owner: await mkEmp('陈总', 'E001', 'BIZ', '企业负责人', 'SINGLE', 25000, 10000, 20000),
bizdir: await mkEmp('王商务', 'E010', 'BIZ', '商务总监', 'SINGLE', 18000, 8000, 18000),
bizstaff: await mkEmp('李专员', 'E011', 'BIZ', '商务专员', 'SINGLE', 9000, 2500, 9000),
dir3d: await mkEmp('赵三维', 'E020', 'TECH_3D', '三维总监', 'SINGLE', 15000, 5000, 15000),
dirfilm: await mkEmp('孙摄制', 'E021', 'TECH_FILM', '摄制总监', 'SINGLE', 15000, 5000, 15000),
dirmaterial: await mkEmp('周物资', 'E022', 'TECH_MATERIAL', '物资总监', 'SINGLE', 15000, 5000, 15000),
dirsoft: await mkEmp('吴软件', 'E023', 'TECH_SOFTWARE', '软件总监', 'DOUBLE', 15000, 5000, 15000),
finance: await mkEmp('郑财务', 'E030', 'FIN', '会计', 'SINGLE', 10000, 2000, 10000),
hr: await mkEmp('冯人事', 'E040', 'HR', '人事专员', 'SINGLE', 9000, 2000, 9000),
employee: await mkEmp('钱员工', 'E100', 'TECH_SOFTWARE', '工程师', 'DOUBLE', 10000, 3000, 10000),
};
const softpm = await mkEmp('软件项目经理', 'E101', 'TECH_SOFTWARE', '项目经理', 'DOUBLE', 12000, 4000, 12000);
await tx.employee.update({
where: { id: emps.employee.id },
data: { managerId: softpm.id },
});
await tx.employee.update({
where: { id: softpm.id },
data: { managerId: emps.dirsoft.id },
});
const mkUser = async (
username: string,
hash: string,
emp: { id: string; name: string },
roleId: string,
) => {
const u = await tx.user.create({
data: {
username,
passwordHash: hash,
displayName: emp.name,
employeeId: emp.id,
},
});
await tx.userRole.create({ data: { userId: u.id, roleId } });
return u;
};
const admin = await mkUser('admin', adminHash, emps.admin, roles.admin.id);
await mkUser('owner', passwordHash, emps.owner, roles.owner.id);
await mkUser('bizdir', passwordHash, emps.bizdir, roles.biz_director.id);
await mkUser('bizstaff', passwordHash, emps.bizstaff, roles.biz_staff.id);
const dirsoft = await mkUser('dirsoft', passwordHash, emps.dirsoft, roles.tech_director.id);
const dir3d = await mkUser('dir3d', passwordHash, emps.dir3d, roles.tech_director.id);
await mkUser('dirfilm', passwordHash, emps.dirfilm, roles.tech_director.id);
await mkUser('dirmaterial', passwordHash, emps.dirmaterial, roles.tech_director.id);
await mkUser('finance', passwordHash, emps.finance, roles.finance.id);
await mkUser('hr', passwordHash, emps.hr, roles.hr.id);
await mkUser('employee', passwordHash, emps.employee, roles.employee.id);
await mkUser('softpm', passwordHash, softpm, roles.employee.id);
const demoEntity = await tx.legalEntity.create({
data: { name: '演示科技有限公司', shortName: '演示科技', creditNo: '91110000MA0000001X', isDefault: true },
});
const party = await tx.businessParty.create({
data: { name: '某市城投集团', creditNo: '91110000MA9999999X', level: 'A' },
});
await tx.partyContact.create({
data: { partyId: party.id, name: '招标办张工', phone: '13800001111', title: '项目联系人' },
});
await tx.qualification.createMany({
data: [
{
name: '电子与智能化工程专业承包',
code: 'QUAL_INTEL',
keeper: '行政',
expiresAt: new Date(Date.now() + 400 * 86400000),
},
{
name: '涉密信息系统集成乙级',
code: 'SECRET_B',
keeper: '行政',
expiresAt: new Date(Date.now() + 20 * 86400000),
},
{
name: '建筑装修装饰工程专业承包',
code: 'QUAL_DECOR',
keeper: '行政',
expiresAt: new Date(Date.now() - 10 * 86400000),
},
],
});
const dicts: [string, string, string, number][] = [
['projectType', '软件', '软件', 1],
['projectType', '三维', '三维', 2],
['projectType', '摄制', '摄制', 3],
['projectType', '物资', '物资', 4],
['projectType', '集成', '集成', 5],
['tenderMethod', '公开招标', '公开招标', 1],
['tenderMethod', '邀请招标', '邀请招标', 2],
['tenderMethod', '询价', '询价', 3],
['tenderMethod', '竞谈【综合评分】', '竞谈【综合评分】', 4],
['tenderMethod', '竞谈【最低价中】', '竞谈【最低价中】', 5],
['tenderMethod', '单一来源', '单一来源', 6],
['tenderMethod', '其他', '其他', 7],
['qualificationNeed', '无需资质', '无需资质', 1],
['qualificationNeed', '军二', '军二', 2],
['qualificationNeed', '涉乙', '涉乙', 3],
['qualificationNeed', '其他', '其他', 4],
['expenseCategory', '交通', '交通', 1],
['expenseCategory', '餐饮', '餐饮', 2],
['expenseCategory', '住宿', '住宿', 3],
['expenseCategory', '办公', '办公', 4],
['expenseCategory', '招待', '招待', 5],
['expenseCategory', '其他', '其他', 6],
['gender', '男', 'MALE', 1],
['gender', '女', 'FEMALE', 2],
['education', '高中及以下', 'HIGH_SCHOOL', 1],
['education', '专科', 'COLLEGE', 2],
['education', '本科', 'BACHELOR', 3],
['education', '硕士', 'MASTER', 4],
['education', '博士', 'DOCTOR', 5],
];
await tx.dictItem.createMany({
data: dicts.map(([dictType, label, value, sortNo]) => ({ dictType, label, value, sortNo })),
});
await tx.numberRule.createMany({
data: [
{ code: 'BID', name: '投标编号', prefix: 'BID', padLength: 5 },
{ code: 'HT', name: '合同编号', prefix: 'HT', padLength: 5 },
{ code: 'PRJ', name: '项目编号', prefix: 'PRJ', padLength: 5 },
{ code: 'BX', name: '报销编号', prefix: 'BX', padLength: 5 },
{ code: 'JK', name: '借款编号', prefix: 'JK', padLength: 5 },
{ code: 'BZ', name: '保证金编号', prefix: 'BZ', padLength: 5 },
],
});
await tx.hrSetting.create({
data: { id: 'default', tenantId: '1', idMask: true, autoAccount: false, probationMonths: 3, defaultRest: 'SINGLE', workStart: '09:00', workEnd: '18:00' },
});
await tx.attendanceRule.createMany({
data: [
{ name: '单休班次', restSchedule: 'SINGLE', workStart: '09:00', workEnd: '18:00', sortNo: 1, remark: '每周休一天,薪酬按 26.09 天核算' },
{ name: '双休班次', restSchedule: 'DOUBLE', workStart: '09:00', workEnd: '18:00', sortNo: 2, remark: '每周休两天,薪酬按 21.75 天核算' },
],
});
const sealTypes = ['公章', '合同章', '法人章', '法人签名章', '项目负责人章', '其他章'];
await tx.sealItem.createMany({
data: sealTypes.map((sealType) => ({
name: `${demoEntity.shortName}${sealType}`,
sealType,
keeper: '行政',
legalEntityId: demoEntity.id,
status: 'IN_STOCK',
})),
});
const bid = await tx.bidCase.create({
data: {
bidNo: 'BID-2026-00001',
name: '某市展厅数字化升级',
externalNo: 'ZFCG-2026-088',
projectType: '集成',
source: '招标公告',
qualificationNeed: '涉乙',
tenderMethod: '公开招标',
applyMethod: '网上申领',
openMethod: '线下开标',
openPlace: '市公共资源交易中心',
openAt: new Date(Date.now() + 7 * 86400000),
partyId: party.id,
status: 'TECH_INITIAL',
createdById: admin.id,
reviewers: {
create: [
{ userId: dirsoft.id, stage: 'TECH_INITIAL' },
{ userId: dir3d.id, stage: 'TECH_INITIAL' },
],
},
},
});
await tx.bidActionLog.create({
data: {
bidCaseId: bid.id,
actorId: admin.id,
action: 'CREATE',
toStatus: 'TECH_INITIAL',
comment: `发起筛选 ${bid.bidNo}`,
},
});
await tx.approvalTask.createMany({
data: [
{
title: `技术初审会签:${bid.name}`,
bizType: 'BID_TECH_INITIAL',
bizId: bid.id,
assigneeId: dirsoft.id,
},
{
title: `技术初审会签:${bid.name}`,
bizType: 'BID_TECH_INITIAL',
bizId: bid.id,
assigneeId: dir3d.id,
},
],
});
});
}
main()
.then(async () => {
await prisma.$disconnect();
console.log('seed ok');
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+165
View File
@@ -0,0 +1,165 @@
import { PrismaClient } from '@prisma/client';
import { DIRECTOR_ROLES, WORK_ASSIGN_ROLES } from '../src/common/role-groups';
import { MENUS, isHrSettingCode, type MenuNode } from './menus';
const prisma = new PrismaClient();
async function upsertNode(node: MenuNode, parentId: string | null, sortNo: number) {
const existing = await prisma.permission.findUnique({ where: { code: node.code } });
const data = {
parentId,
name: node.name,
permType: 'MENU',
path: node.path,
icon: node.icon ?? null,
sortNo,
visible: true,
};
const row = existing
? await prisma.permission.update({ where: { id: existing.id }, data })
: await prisma.permission.create({ data: { ...data, code: node.code } });
return row;
}
const ACTION_DEFS = [
{ name: '查看', suffix: 'view' },
{ name: '新增', suffix: 'create' },
{ name: '编辑', suffix: 'edit' },
{ name: '删除', suffix: 'delete' },
{ name: '审批', suffix: 'approve' },
];
async function upsertActions(parentCode: string, parentId: string) {
const ids: string[] = [];
for (let i = 0; i < ACTION_DEFS.length; i++) {
const a = ACTION_DEFS[i];
const code = `${parentCode}:${a.suffix}`;
const existing = await prisma.permission.findUnique({ where: { code } });
const data = {
parentId,
name: a.name,
permType: 'ACTION',
path: null as string | null,
icon: null as string | null,
sortNo: i + 1,
visible: true,
};
const row = existing
? await prisma.permission.update({ where: { id: existing.id }, data })
: await prisma.permission.create({ data: { ...data, code } });
ids.push(row.id);
}
return ids;
}
async function walk(nodes: MenuNode[], parentId: string | null, root = true) {
const ids: string[] = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const row = await upsertNode(node, parentId, root ? (i + 1) * 10 : i + 1);
ids.push(row.id);
if (node.children?.length) ids.push(...(await walk(node.children, row.id, false)));
else ids.push(...(await upsertActions(node.code, row.id)));
}
return ids;
}
async function main() {
const kept = await walk(MENUS, null);
const stale = await prisma.permission.findMany({
where: {
permType: 'MENU',
OR: [
{ id: { notIn: kept }, code: { in: ['system:org'] } },
{ code: { in: ['office:loan-approve', 'finance:expense-approve', 'finance:loan-approve', 'finance:bonds', 'contract:milestones', 'office:attendance', 'office:policies', 'project:budgets', 'project:changes'] } },
],
},
});
if (stale.length) {
await prisma.permission.updateMany({
where: { id: { in: stale.map((s) => s.id) } },
data: { visible: false },
});
}
const all = await prisma.permission.findMany({ where: { visible: true } });
const grantByCodes = (codes: string[]) => {
if (codes[0] === '*') return all;
return all.filter((p) =>
codes.some((c) => {
if (c.endsWith(':')) return p.code === c.slice(0, -1) || p.code.startsWith(c);
return p.code === c || p.code.startsWith(`${c}:`);
}),
);
};
const STAFF_OFFICE = [
'office:overview',
'office',
'office:todos',
'office:approvals',
'office:calendar',
'office:reports',
'office:expenses',
'office:flow',
'office:hr-apply',
'office:seal-apply',
'office:apply',
'office:notices',
'office:directory',
'office:im',
];
const ROLE_GRANT: Record<string, string[]> = {
admin: ['*'],
owner: ['*'],
biz_director: ['office:', 'party:', 'seal:', 'bid:', 'contract:', 'finance:', 'report:', 'hr:', 'asset:', 'system:hr', 'system:org', 'system:attendance-rules'],
biz_staff: ['office:', 'party:', 'seal:', 'bid:', 'contract:', 'finance:', 'asset:', 'hr:employees', 'hr:payroll', 'hr:performance', 'hr:attendance', 'hr:lifecycle'],
tech_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'],
rd_director: ['*'],
'3d_director': ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'],
video_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'],
material_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'],
finance: ['office:', 'finance:', 'contract:', 'report:', 'bid:result', 'asset:'],
hr: ['office:', 'hr:', 'contract:hr', 'system:hr', 'system:org', 'system:attendance-rules', 'system:acl'],
employee: [...STAFF_OFFICE, 'project:timesheets'],
bid_screener: ['office', 'office:overview', 'bid', 'bid:screening'],
pm: [...STAFF_OFFICE, 'office:assign', 'project:'],
rd_staff: [...STAFF_OFFICE, 'project:timesheets'],
'3d_staff': [...STAFF_OFFICE, 'project:timesheets'],
video_staff: [...STAFF_OFFICE, 'project:timesheets'],
material_staff: [...STAFF_OFFICE, 'project:timesheets'],
tech: [...STAFF_OFFICE, 'project:timesheets'],
};
const roles = await prisma.role.findMany();
for (const role of roles) {
if (role.aclCustom) continue;
const codes = ROLE_GRANT[role.code];
if (!codes) continue;
const wanted = grantByCodes(codes);
const extra = role.code === 'hr' ? all.filter((p) => isHrSettingCode(p.code)) : [];
const smsRoles = ['admin', 'owner', 'hr', ...DIRECTOR_ROLES];
const merged = [...new Map([...wanted, ...extra].map((p) => [p.id, p])).values()].filter((p) => {
if (p.code === 'office:assign') return WORK_ASSIGN_ROLES.includes(role.code);
if (p.code === 'office:sms') return smsRoles.includes(role.code);
return true;
});
const wantedIds = merged.map((p) => p.id);
await prisma.rolePermission.deleteMany({
where: { roleId: role.id, permissionId: { notIn: wantedIds.length ? wantedIds : [''] } },
});
await prisma.rolePermission.createMany({
data: merged.map((p) => ({ roleId: role.id, permissionId: p.id })),
skipDuplicates: true,
});
}
console.log(`menus synced: ${kept.length} visible, hid ${stale.length}`);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+329
View File
@@ -0,0 +1,329 @@
import { PrismaClient } from '@prisma/client';
import { createHash, randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { parseOaJson } from './oa-json';
const prisma = new PrismaClient();
const CORE = '/opt/import/prod/oa_core.json';
const FLOW = '/opt/import/prod/oa_flow_finance.json';
const BID_FILES = '/opt/import/prod/oa_bid_files.json';
const HR = '/opt/import/prod/oa_hr.json';
const OA_FILES = '/opt/import/prod/oa-files';
const UPLOAD = path.join(process.cwd(), 'uploads');
type Row = Record<string, unknown>;
const MIME: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
zip: 'application/zip',
rar: 'application/x-rar-compressed',
};
const NAME_ALIAS: Record<string, string> = { : '张婧妍' };
const SKIP_DOC_ROLE = new Set(['draft', 'final', 'review1']);
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
function profilePaths(v: unknown) {
const out: string[] = [];
if (!v) return out;
for (const chunk of str(v).split(/[,;]/)) {
const piece = chunk.trim();
const first = piece.split('|')[0].trim();
const idx = first.indexOf('/profile/upload/');
const p = idx >= 0 ? first.slice(idx) : first;
if (p.startsWith('/profile/upload/')) out.push(p);
}
return out;
}
function relOf(p: string) {
return p.replace(/^\/profile\/upload\//, '');
}
function displayName(p: string) {
const pipe = str(p).split('|')[1];
if (pipe && !pipe.includes('/')) return path.basename(pipe);
return path.basename(relOf(p.split('|')[0]));
}
function mimeOf(name: string) {
const ext = name.split('.').pop()?.toLowerCase() || 'bin';
return MIME[ext] || 'application/octet-stream';
}
function prettyName(name: string) {
try {
return decodeURIComponent(name);
} catch {
return name;
}
}
async function attach(
bizType: string,
bizId: string,
urls: unknown,
uploadedById: string,
seen: Set<string>,
) {
let n = 0;
let missing = 0;
for (const raw of profilePaths(urls)) {
const rel = relOf(raw);
const src = path.join(OA_FILES, rel);
if (!fs.existsSync(src)) {
console.warn(`[missing-source] ${bizType} ${rel}`);
missing += 1;
continue;
}
const buf = fs.readFileSync(src);
const hash = createHash('sha1').update(buf).digest('hex').slice(0, 16);
const key = `${bizId}:${hash}`;
if (seen.has(key)) continue;
seen.add(key);
const id = randomUUID();
const ext = (path.extname(rel).replace('.', '') || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
const storageKey = `OA/${bizType}/${bizId}/${id}.${ext || 'bin'}`;
const dest = path.join(UPLOAD, storageKey);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
const name = prettyName(displayName(raw) || path.basename(rel));
await prisma.fileAsset.create({
data: {
id,
tenantId: '1',
bizType,
bizId,
fileName: name.slice(0, 180),
mimeType: mimeOf(name),
size: buf.length,
storageKey,
uploadedById,
},
});
n += 1;
}
return { n, missing };
}
async function main() {
const core = parseOaJson<Record<string, Row[]>>(CORE);
const flow = parseOaJson<Record<string, Row[]>>(FLOW);
const docFiles = JSON.parse(fs.readFileSync(BID_FILES, 'utf8')) as Row[];
const hr = JSON.parse(fs.readFileSync(HR, 'utf8')) as { users: { id: number; userName: string; nickName: string }[] };
const admin = await prisma.user.findFirst({
where: { OR: [{ username: 'admin' }, { displayName: '系统管理员' }] },
orderBy: { createdAt: 'asc' },
});
if (!admin) throw new Error('没有可用于迁移附件的系统管理员账号');
fs.mkdirSync(UPLOAD, { recursive: true });
const users = await prisma.user.findMany();
const byUsername = new Map(users.map((u) => [u.username, u]));
const byName = new Map(users.map((u) => [u.displayName, u]));
for (const [a, b] of Object.entries(NAME_ALIAS)) {
const u = byName.get(b);
if (u) byName.set(a, u);
}
const oaUser = new Map(hr.users.map((u) => [String(u.id), u.userName]));
const userFromOaId = (id: unknown) => {
const uname = oaUser.get(str(id));
return uname ? byUsername.get(uname) : undefined;
};
const userFromName = (name: unknown) => {
const n = NAME_ALIAS[str(name)] || str(name);
return byName.get(n);
};
const existing = await prisma.fileAsset.findMany({ select: { bizId: true, storageKey: true, size: true, fileName: true } });
const seen = new Set<string>();
for (const f of existing) {
const src = path.join(process.cwd(), 'uploads', f.storageKey);
if (!fs.existsSync(src)) continue;
const hash = createHash('sha1').update(fs.readFileSync(src)).digest('hex').slice(0, 16);
seen.add(`${f.bizId}:${hash}`);
}
const bids = await prisma.bidCase.findMany();
const findBids = (nos: string[], name?: string) => {
const wanted = new Set(nos.filter(Boolean));
const hits = bids.filter(
(b) => wanted.has(b.bidNo) || (b.externalNo && wanted.has(b.externalNo)),
);
if (hits.length) return hits;
if (name) return bids.filter((b) => b.name === name);
return [];
};
const stats = {
bidAnnounce: 0,
bidFile: 0,
bidProof: 0,
contracts: 0,
expenses: 0,
projects: 0,
purchases: 0,
missing: 0,
unmatchedBids: 0,
};
for (const t of core.oa_tender || []) {
const localHits = findBids(
[officialNo(t.project_number), str(t.system_number)],
str(t.project_name),
);
if (!localHits.length) continue;
const who = userFromOaId(t.create_by) || userFromName(t.bid_prepare_person) || admin;
for (const local of localHits) {
stats.bidAnnounce += (await attach('BID_ANNOUNCE', local.id, t.announcement_file, who.id, seen)).n;
const extra = await attach('BID_FILE', local.id, t.attachment, who.id, seen);
stats.bidFile += extra.n;
stats.missing += extra.missing;
stats.bidProof += (await attach('BID_PROOF', local.id, t.submit_proof_file, who.id, seen)).n;
}
}
for (const p of flow.project_preinvest_apply || []) {
const nos = [str(p.apply_no), officialNo(p.project_number)];
const localHits = findBids(nos, str(p.project_name));
if (!localHits.length) {
if (profilePaths(p.attachment).length || profilePaths(p.business_attachment).length) stats.unmatchedBids += 1;
continue;
}
const who = userFromName(p.applicant_name) || userFromName(p.business_handler_name) || admin;
for (const local of localHits) {
const a = await attach('BID_ANNOUNCE', local.id, p.attachment, who.id, seen);
const b = await attach('BID_FILE', local.id, p.business_attachment, who.id, seen);
const c = await attach('BID_PROOF', local.id, p.material_decision_proof, who.id, seen);
const d = await attach('BID_FILE', local.id, p.business_prep_attachment, who.id, seen);
stats.bidAnnounce += a.n;
stats.bidFile += b.n + d.n;
stats.bidProof += c.n;
stats.missing += a.missing + b.missing + c.missing + d.missing;
}
}
const taskById = new Map((flow.bid_document_task || []).map((t) => [String(t.id), t]));
for (const f of docFiles) {
if (SKIP_DOC_ROLE.has(str(f.file_role))) continue;
const task = taskById.get(str(f.task_id));
const nos = task
? [str(task.task_no), officialNo(task.project_number), str(task.preinvest_apply_no)]
: [];
const localHits = findBids(nos, str(task?.project_name));
if (!localHits.length) {
stats.unmatchedBids += 1;
continue;
}
const who = userFromName(f.uploaded_by_name) || userFromName(task?.dispatcher_name) || admin;
const role = str(f.file_role);
const from = str(f.source_preinvest_field);
const bizType =
role === 'submit_proof' || from === 'material_decision_proof'
? 'BID_PROOF'
: from === 'attachment'
? 'BID_ANNOUNCE'
: 'BID_FILE';
for (const local of localHits) {
const r = await attach(bizType, local.id, f.file_url, who.id, seen);
if (bizType === 'BID_ANNOUNCE') stats.bidAnnounce += r.n;
else if (bizType === 'BID_PROOF') stats.bidProof += r.n;
else stats.bidFile += r.n;
stats.missing += r.missing;
}
}
const contracts = await prisma.contract.findMany();
const contractByName = new Map(contracts.map((c) => [c.name, c]));
for (const c of core.oa_contract || []) {
const local = contractByName.get(str(c.contract_name));
if (!local) continue;
const who = userFromOaId(c.create_by) || userFromOaId(c.creator_id) || admin;
stats.contracts += (await attach('CONTRACT', local.id, c.contract_original_url, who.id, seen)).n;
stats.contracts += (await attach('CONTRACT_ACCEPT', local.id, c.acceptance_url, who.id, seen)).n;
stats.contracts += (await attach('CONTRACT_AWARD', local.id, c.award_notice_url, who.id, seen)).n;
}
const expenses = await prisma.expenseClaim.findMany();
for (const r of flow.reimbursement || core.reimbursement || []) {
const amount = num(r.total_amount);
const name = str(r.employee_name);
const claimNo = str(r.reimburse_no);
const oaId = str(r.id);
if (!oaId) continue;
let hits = claimNo ? expenses.filter((e) => e.claimNo === claimNo) : [];
if (!hits.length) {
hits = expenses.filter((e) => Math.abs(Number(e.amount) - amount) < 0.009 && name && (e.remark || '').includes(name));
}
const local = hits[0];
if (!local) continue;
const who = userFromName(name) || users.find((u) => u.id === local.applicantId) || admin;
const items = (flow.reimbursement_item || core.reimbursement_item || []).filter(
(i) => str(i.reimbursement_id) && str(i.reimbursement_id) === oaId,
);
for (const item of items) {
const x = await attach('EXPENSE', local.id, item.url, who.id, seen);
stats.expenses += x.n;
stats.missing += x.missing;
}
const x = await attach('EXPENSE', local.id, r.attachment_url, who.id, seen);
stats.expenses += x.n;
stats.missing += x.missing;
}
const projects = await prisma.project.findMany();
const projectByName = new Map(projects.map((p) => [p.name, p]));
for (const p of core.project_list || []) {
const local =
projects.find((x) => x.projectNo === str(p.system_number)) || projectByName.get(str(p.project_name));
if (!local) continue;
stats.projects += (await attach('PROJECT', local.id, p.bzj_attachment, admin.id, seen)).n;
}
const purchases = await prisma.purchaseRequest.findMany();
for (const r of core.oa_purchasing || []) {
const titleBits = [str(r.contract_no), str(r.contract_name)].filter(Boolean);
const local = purchases.find((p) => titleBits.some((b) => p.title.includes(b)));
if (!local) continue;
const who = userFromOaId(r.create_by) || userFromOaId(r.creator_id) || admin;
stats.purchases += (await attach('PURCHASE', local.id, r.contract_original_url, who.id, seen)).n;
stats.purchases += (await attach('PURCHASE_ACCEPT', local.id, r.acceptance_url, who.id, seen)).n;
}
const fileCount = await prisma.fileAsset.count();
const byType = await prisma.fileAsset.groupBy({ by: ['bizType'], _count: true });
console.log(JSON.stringify({ added: stats, fileAssetTotal: fileCount, byType }, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+35
View File
@@ -0,0 +1,35 @@
import { PrismaClient } from '@prisma/client';
const ITEMS = [
['公开招标', 1],
['邀请招标', 2],
['询价', 3],
['竞谈【综合评分】', 4],
['竞谈【最低价中】', 5],
['单一来源', 6],
['其他', 7],
] as const;
const prisma = new PrismaClient();
async function main() {
await prisma.dictItem.deleteMany({ where: { tenantId: '1', dictType: 'tenderMethod' } });
await prisma.dictItem.createMany({
data: ITEMS.map(([value, sortNo]) => ({
tenantId: '1',
dictType: 'tenderMethod',
label: value,
value,
sortNo,
})),
});
console.log('tenderMethod dict synced', ITEMS.length);
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+131
View File
@@ -0,0 +1,131 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_core.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function mapQual(raw: unknown) {
const s = str(raw);
if (!s) return '';
try {
const arr = JSON.parse(s) as { item?: string }[];
const item = str(arr?.[0]?.item);
if (!item || item === '无') return '无需资质';
if (item.includes('军')) return '军二';
if (item.includes('涉')) return '涉乙';
return item.slice(0, 20);
} catch {
if (s.includes('军')) return '军二';
if (s.includes('涉')) return '涉乙';
return '';
}
}
function mapStatus(row: Row) {
const st = str(row.project_status);
const win = str(row.winning_status);
if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON';
if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST';
if (st === '流标') return 'FAILED';
if (st === '弃标' || win.includes('弃标')) return 'TERMINATED';
return '';
}
function isRealTender(row: Row) {
return /^TB-\d/.test(str(row.system_number)) && Boolean(str(row.project_name));
}
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const tenders = (raw.oa_tender || []).filter(isRealTender);
const bids = await prisma.bidCase.findMany();
const users = await prisma.user.findMany({ select: { id: true, displayName: true } });
const userByName = new Map(users.map((u) => [u.displayName, u.id]));
userByName.set('张靖妍', userByName.get('张婧妍') || '');
const testNos = ['SP-TEST-1788068192', 'SP-FIX-1788068273'];
const tests = bids.filter((b) => testNos.includes(b.bidNo) || testNos.includes(b.externalNo || ''));
const testIds = tests.map((b) => b.id);
if (testIds.length) {
await prisma.todoTask.deleteMany({ where: { bizId: { in: testIds } } });
await prisma.approvalTask.deleteMany({ where: { bizId: { in: testIds } } });
await prisma.bidCase.deleteMany({ where: { id: { in: testIds } } });
}
const used = new Set<string>();
const remaining = await prisma.bidCase.findMany();
let patched = 0;
const moves: { name: string; from: string; to: string; no: string }[] = [];
for (const t of tenders) {
const name = str(t.project_name);
const sys = str(t.system_number);
const official = officialNo(t.project_number);
const row =
remaining.find((b) => b.name === name) ||
remaining.find((b) => b.bidNo === sys || b.externalNo === official || b.externalNo === str(t.project_number));
if (!row) continue;
const status = mapStatus(t) || row.status;
const creatorName = str(t.bid_prepare_person) === '张靖妍' ? '张婧妍' : str(t.bid_prepare_person);
let bidNo = official || sys;
if (used.has(bidNo)) bidNo = `${official || sys}-${sys.slice(-3)}`;
used.add(bidNo);
if (row.status !== status) {
moves.push({ name: name.slice(0, 24), from: row.status, to: status, no: official || sys });
}
const qual = mapQual(t.bid_doc_requirement);
await prisma.bidCase.update({
where: { id: row.id },
data: {
bidNo: `TMP-${row.id.replace(/-/g, '').slice(0, 18)}`,
},
});
await prisma.bidCase.update({
where: { id: row.id },
data: {
bidNo,
externalNo: official || null,
status,
sourceType: 'FLOW',
qualificationNeed: qual || row.qualificationNeed,
tenderMethod: str(t.tender_method) || row.tenderMethod,
applyMethod: row.applyMethod || (str(t.announcement_file) ? '网上申领' : row.applyMethod),
delivererName: str(t.bid_delivery_person) || row.delivererName,
createdById: userByName.get(creatorName) || row.createdById,
},
});
patched += 1;
}
const after = await prisma.bidCase.groupBy({ by: ['status'], _count: true });
console.log(
JSON.stringify(
{ deletedTests: testIds.length, patched, moves, counts: after },
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+142
View File
@@ -0,0 +1,142 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_clean.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
const CANON = [
{ name: '江苏风影随行科技有限公司', shortName: '随行科技', aliases: ['江苏风影随行科技有限公司', '江苏风影随行', '随行科技'] },
{ name: '江苏风影软件有限公司', shortName: '风影软件', aliases: ['江苏风影软件有限公司', '风影软件'] },
{ name: '江苏风影工程科技有限公司', shortName: '风影工程', aliases: ['江苏风影工程科技有限公司', '风影工程'] },
{ name: '山东鲁兵', shortName: '山东鲁兵', aliases: ['山东鲁兵', '鲁兵'] },
{ name: '隆创', shortName: '隆创', aliases: ['隆创'] },
] as const;
function canonOf(raw: string) {
const s = str(raw);
if (!s) return null;
if (/软件/.test(s) && /风影/.test(s)) return CANON[1];
if (/工程/.test(s) && /风影/.test(s)) return CANON[2];
if (/随行/.test(s) || s === '江苏风影' || (s.includes('风影') && !/软件|工程/.test(s))) return CANON[0];
if (s.includes('鲁兵')) return CANON[3];
if (s.includes('隆创')) return CANON[4];
for (const c of CANON) {
if (c.aliases.some((a) => s === a || s.includes(a))) return c;
}
return null;
}
async function main() {
const ids = new Map<string, string>();
for (const c of CANON) {
let row =
(await prisma.legalEntity.findFirst({ where: { tenantId: '1', name: c.name } })) ||
(await prisma.legalEntity.findFirst({
where: { tenantId: '1', OR: c.aliases.map((a) => ({ name: a })) },
}));
if (row) {
row = await prisma.legalEntity.update({
where: { id: row.id },
data: { name: c.name, shortName: c.shortName, isDefault: c.name === '江苏风影随行科技有限公司' },
});
} else {
row = await prisma.legalEntity.create({
data: {
name: c.name,
shortName: c.shortName,
isDefault: c.name === '江苏风影随行科技有限公司',
},
});
}
ids.set(c.name, row.id);
}
const all = await prisma.legalEntity.findMany({ where: { tenantId: '1' } });
const keep = new Set(ids.values());
let merged = 0;
for (const extra of all) {
if (keep.has(extra.id)) continue;
const hit = canonOf(extra.name) || canonOf(extra.shortName);
const target = hit ? ids.get(hit.name) : ids.get('江苏风影随行科技有限公司');
if (!target || target === extra.id) continue;
const n = await prisma.bidCase.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } });
await prisma.contract.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null);
await prisma.sealItem.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null);
await prisma.registeredAsset.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null);
await prisma.legalEntity.delete({ where: { id: extra.id } }).catch(() => null);
merged += 1;
console.log('merged', extra.name, '->', hit?.name, 'bids', n.count);
}
await prisma.legalEntity.updateMany({
where: { name: { not: '江苏风影随行科技有限公司' } },
data: { isDefault: false },
});
await prisma.legalEntity.updateMany({
where: { name: '江苏风影随行科技有限公司' },
data: { isDefault: true },
});
let remapped = 0;
if (fs.existsSync(DUMP)) {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const tenders = raw.oa_tender || [];
const bids = await prisma.bidCase.findMany({ select: { id: true, name: true, bidNo: true, externalNo: true } });
for (const t of tenders) {
const name = str(t.project_name);
const sys = str(t.system_number);
const official = str(t.project_number);
const row =
bids.find((b) => b.name === name) ||
bids.find((b) => b.bidNo === sys || b.externalNo === official || b.bidNo === official);
const hit = canonOf(str(t.participating_unit));
const legalEntityId = hit ? ids.get(hit.name) : undefined;
const winner = str(t.winning_unit);
const amtRaw = str(t.winning_amount);
const status = str(t.winning_status);
if (!row) continue;
const data: { legalEntityId?: string; winnerName?: string; winnerAmount?: number } = {};
if (legalEntityId) data.legalEntityId = legalEntityId;
if (winner && status && !/中标/.test(status)) {
data.winnerName = winner;
if (amtRaw && !Number.isNaN(Number(amtRaw))) data.winnerAmount = Number(amtRaw);
}
if (!Object.keys(data).length) continue;
await prisma.bidCase.update({ where: { id: row.id }, data });
remapped += 1;
}
}
const leftover = await prisma.legalEntity.findMany({ where: { tenantId: '1' }, orderBy: { name: 'asc' } });
const grouped = await prisma.bidCase.groupBy({ by: ['legalEntityId'], _count: true });
console.log(
JSON.stringify(
{
entities: leftover.map((e) => ({ name: e.name, shortName: e.shortName, isDefault: e.isDefault })),
bids: grouped.map((g) => ({
id: g.legalEntityId,
count: g._count,
name: leftover.find((e) => e.id === g.legalEntityId)?.name || '(空)',
})),
merged,
remapped,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+466
View File
@@ -0,0 +1,466 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { mapExpenseLineKind, parseOaJson } from './oa-json';
const prisma = new PrismaClient();
const FLOW = '/opt/import/prod/oa_flow_finance.json';
const FRESH = '/opt/import/prod/oa_fresh.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 200) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s;
}
function compactName(name: string) {
return str(name)
.replace(/[()()\[\]【】\s\-—_.,,、]/g, '')
.replace(/招标公告|采购项目|技术服务合同|合同/g, '');
}
function namesOverlap(a: string, b: string) {
const na = compactName(a);
const nb = compactName(b);
if (!na || !nb) return false;
if (na === nb) return true;
const shorter = na.length <= nb.length ? na : nb;
const longer = na.length <= nb.length ? nb : na;
return shorter.length >= 10 && longer.includes(shorter);
}
function mapProjectType(v: unknown, projectName?: unknown) {
const name = str(projectName);
if (name.includes('三维')) return '三维';
if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制';
const s = str(v);
if (s.includes('三维')) return '三维';
if (s.includes('摄制') || s.includes('视频')) return '摄制';
if (s.includes('物资') && s.includes('软件')) return '集成';
if (s.includes('物资')) return '物资';
if (s.includes('集成')) return '集成';
if (s === '软件') return '软件';
return '软件';
}
function mapDocStatus(stage: number, approval: number) {
if (stage === 8 || approval === 3) return 'TERMINATED';
if (stage === 1 || stage === 2 || stage === 3 || stage === 4 || stage === 5 || stage === 6) return 'DRAFTING';
if (stage === 7 && approval === 2) return 'PENDING';
return 'DRAFTING';
}
function mapPreinvestStatus(stage: number, approval: number) {
if (stage === 6 || approval === 3) return 'TERMINATED';
if (stage === 3) return 'TECH_FINAL';
if (stage === 1) return 'TECH_INITIAL';
if (stage === 5 && approval === 2) return '';
return 'FETCH';
}
function mapExpenseStatus(v: unknown) {
const s = str(v);
if (s === '2') return 'APPROVED';
if (s === '3') return 'REJECTED';
if (s === '4') return 'DRAFT';
return 'PENDING';
}
function mapLoanStatus(row: Row) {
const st = str(row.approval_status);
const offset = num(row.offset_amount);
if (st === '2') return offset >= num(row.amount) && offset > 0 ? 'OFFSET' : 'OPEN';
if (st === '1') return 'PENDING';
if (st === '3') return 'REJECTED';
return 'DRAFT';
}
function mapItemKind(itemType: string, remark = '') {
return mapExpenseLineKind(itemType, remark);
}
function mapItemCategory(itemType: string) {
if (itemType.includes('交通') || itemType.includes('差旅') || itemType.includes('机票') || itemType.includes('高铁')) return '交通';
if (itemType.includes('餐')) return '餐饮';
if (itemType.includes('住宿')) return '住宿';
if (itemType.includes('办公')) return '办公';
if (itemType.includes('招待')) return '招待';
return '其他';
}
function isBidExpense(itemType: string) {
return itemType.includes('投标') || itemType.includes('制标') || itemType.includes('取标') || itemType.includes('印');
}
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function inferTenderMethod(name: string, stored?: string) {
const s = str(stored);
if (s) return s;
const n = name || '';
if (n.includes('询价')) return '询价';
if (n.includes('邀请招标')) return '邀请招标';
if (n.includes('单一来源')) return '单一来源';
if (n.includes('竞谈最低') || n.includes('竞争性谈判(最低') || n.includes('竞争性谈判(最低')) return '竞谈最低价';
if (n.includes('竞谈综合') || n.includes('综合评审')) return '竞谈综合评审';
if (n.includes('竞争性谈判') || n.includes('竞谈')) return '竞争性谈判';
if (n.includes('竞争性磋商') || n.includes('磋商')) return '竞争性磋商';
if (n.includes('公开招标')) return '公开招标';
return '';
}
async function main() {
const flow = parseOaJson<Record<string, Row[]>>(FLOW);
const fresh = parseOaJson<Record<string, Row[]>>(FRESH);
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('本地没有 admin');
const entity =
(await prisma.legalEntity.findFirst({ where: { tenantId: '1', isDefault: true } })) ||
(await prisma.legalEntity.findFirst({ where: { tenantId: '1' } }));
if (!entity) throw new Error('本地没有主体');
const users = await prisma.user.findMany({ select: { id: true, displayName: true, username: true } });
const userByName = new Map(users.map((u) => [u.displayName, u.id]));
userByName.set('张靖妍', userByName.get('张婧妍') || '');
const entities = await prisma.legalEntity.findMany();
const entityByName = new Map(entities.map((e) => [e.name, e.id]));
for (const e of entities) if (e.shortName) entityByName.set(e.shortName, e.id);
const projects = await prisma.project.findMany({ select: { id: true, name: true, projectNo: true } });
const projectByName = new Map(projects.map((p) => [p.name, p.id]));
const projectByOaId = new Map<string, string>();
for (const p of fresh.project_list || []) {
const local = projects.find((x) => x.projectNo === str(p.system_number) || namesOverlap(x.name, str(p.project_name)));
if (local && p.project_id != null) projectByOaId.set(String(p.project_id), local.id);
}
const tenders = (fresh.oa_tender || []).filter((t) => /^TB-\d/.test(str(t.system_number)));
const tenderById = new Map(tenders.map((t) => [String(t.id), t]));
const allPreinvests = flow.project_preinvest_apply || [];
const preByTask = new Map<string, Row>();
const preByTender = new Map<string, Row>();
const preByName = new Map<string, Row>();
for (const p of [...allPreinvests].sort((a, b) => num(a.deleted) - num(b.deleted))) {
if (p.bid_document_task_id != null) preByTask.set(String(p.bid_document_task_id), p);
if (p.tender_id != null) preByTender.set(String(p.tender_id), p);
if (str(p.project_name) && !preByName.has(str(p.project_name))) preByName.set(str(p.project_name), p);
}
const bids = await prisma.bidCase.findMany();
const usedNos = new Set(bids.map((b) => b.bidNo));
function findBid(_name: string, nos: string[]) {
const wanted = new Set(nos.filter(Boolean));
if (!wanted.size) return undefined;
return bids.find((b) => wanted.has(b.bidNo) || (b.externalNo && wanted.has(b.externalNo)));
}
let applyPatched = 0;
for (const t of tenders) {
const pre = preByTender.get(String(t.id)) || preByName.get(str(t.project_name));
const start = when(pre?.file_apply_time_start) || when(t.bid_prepare_date) || when(t.create_time);
const end = when(pre?.file_apply_time_end) || when(t.file_apply_deadline);
const row = findBid(str(t.project_name), [officialNo(t.project_number), str(t.system_number)]);
if (!row || (!start && !end)) continue;
await prisma.bidCase.update({
where: { id: row.id },
data: {
applyStartAt: start || row.applyStartAt,
applyEndAt: end || row.applyEndAt,
applyMethod: clip(pre?.apply_method, 50) || row.applyMethod,
openMethod: clip(pre?.bid_open_method, 50) || row.openMethod,
},
});
applyPatched += 1;
}
async function ensureBid(opts: {
bidNo: string;
name: string;
official?: string;
status: string;
openAt?: Date | null;
openPlace?: string;
applyStart?: Date | null;
applyEnd?: Date | null;
applyMethod?: string;
openMethod?: string;
projectType?: string;
source?: string;
qualification?: string;
entityName?: string;
createdAt?: Date | null;
createdByName?: string;
tenderMethod?: string;
}) {
if (!opts.name || /^test/i.test(opts.name)) return 'skip';
const existing = findBid(opts.name, [opts.bidNo, opts.official || '']);
if (existing) {
const result = ['WON', 'LOST', 'FAILED'].includes(existing.status);
const sameNo = existing.bidNo === opts.bidNo;
if (opts.status === 'TERMINATED' && !sameNo && !result && existing.status !== 'TERMINATED') {
// 同名在途单不因回收站记录被误杀,下面另建终止单
} else {
const nextStatus = result ? existing.status : opts.status === 'TERMINATED' || existing.status === 'TERMINATED' ? 'TERMINATED' : opts.status;
await prisma.bidCase.update({
where: { id: existing.id },
data: {
applyStartAt: opts.applyStart || existing.applyStartAt,
applyEndAt: opts.applyEnd || existing.applyEndAt,
applyMethod: opts.applyMethod || existing.applyMethod,
openMethod: opts.openMethod || existing.openMethod,
openAt: opts.openAt || existing.openAt,
openPlace: opts.openPlace || existing.openPlace,
projectType: mapProjectType(opts.projectType, opts.name) || existing.projectType,
tenderMethod: inferTenderMethod(opts.name, opts.tenderMethod) || existing.tenderMethod,
status: nextStatus,
},
});
return 'updated';
}
}
let bidNo = opts.bidNo;
if (!bidNo || usedNos.has(bidNo)) bidNo = opts.official || opts.bidNo;
if (usedNos.has(bidNo)) bidNo = `${opts.bidNo}-${Date.now().toString().slice(-4)}`;
usedNos.add(bidNo);
const created = await prisma.bidCase.create({
data: {
bidNo,
name: clip(opts.name, 200),
externalNo: opts.official || undefined,
projectType: mapProjectType(opts.projectType, opts.name),
sourceType: 'FLOW',
source: clip(opts.source, 50) || undefined,
qualificationNeed: clip(opts.qualification, 40) || undefined,
legalEntityId: (opts.entityName && entityByName.get(opts.entityName)) || entity.id,
applyStartAt: opts.applyStart || undefined,
applyEndAt: opts.applyEnd || undefined,
applyMethod: clip(opts.applyMethod, 50) || undefined,
openAt: opts.openAt || undefined,
openMethod: clip(opts.openMethod, 50) || undefined,
openPlace: clip(opts.openPlace, 200) || undefined,
tenderMethod: inferTenderMethod(opts.name, opts.tenderMethod) || undefined,
status: opts.status,
createdById: userByName.get(opts.createdByName || '') || admin.id,
createdAt: opts.createdAt || undefined,
},
});
bids.push(created);
return 'created';
}
const created = { drafting: 0, screening: 0, terminated: 0, other: 0, updated: 0, skipped: 0 };
const tasks = (flow.bid_document_task || []).filter((t) => str(t.project_name));
for (const t of tasks) {
const pre = preByTask.get(String(t.id)) || preByName.get(str(t.project_name));
const tender = t.tender_id != null ? tenderById.get(String(t.tender_id)) : undefined;
const deleted = num(t.is_deleted) === 1;
const status = deleted
? 'TERMINATED'
: tender
? undefined
: mapDocStatus(num(t.workflow_stage), num(t.approval_status));
if (!status && tender) {
const r = await ensureBid({
bidNo: str(t.task_no),
name: str(t.project_name),
official: officialNo(t.project_number) || officialNo(tender.project_number),
status: 'PENDING',
openAt: when(t.bid_open_time) || when(tender.bid_open_date),
openPlace: clip(t.bid_open_location, 200),
applyStart: when(pre?.file_apply_time_start) || when(tender.bid_prepare_date),
applyEnd: when(pre?.file_apply_time_end) || when(t.file_apply_time_end) || when(tender.file_apply_deadline),
applyMethod: str(pre?.apply_method),
openMethod: str(pre?.bid_open_method),
projectType: str(pre?.screening_category || t.screening_category || t.project_type || tender.project_type),
source: str(t.project_source || pre?.project_source),
qualification: str(pre?.qualification_require),
entityName: str(pre?.bid_entity),
createdAt: when(t.created_time),
createdByName: str(t.dispatcher_name),
tenderMethod: str(t.bid_method || tender.tender_method),
});
if (r === 'updated') created.updated += 1;
else if (r === 'skip') created.skipped += 1;
continue;
}
const next = status || 'DRAFTING';
const r = await ensureBid({
bidNo: str(t.task_no),
name: str(t.project_name),
official: officialNo(t.project_number),
status: next,
openAt: when(t.bid_open_time),
openPlace: clip(t.bid_open_location, 200),
applyStart: when(pre?.file_apply_time_start),
applyEnd: when(pre?.file_apply_time_end) || when(t.file_apply_time_end),
applyMethod: str(pre?.apply_method),
openMethod: str(pre?.bid_open_method),
projectType: str(pre?.screening_category || t.screening_category || t.project_type || pre?.project_type),
source: str(t.project_source || pre?.project_source),
qualification: str(pre?.qualification_require),
entityName: str(pre?.bid_entity),
createdAt: when(t.created_time),
createdByName: str(t.dispatcher_name),
tenderMethod: str(t.bid_method),
});
if (r === 'created') {
if (next === 'DRAFTING') created.drafting += 1;
else if (next === 'TERMINATED') created.terminated += 1;
else created.other += 1;
} else if (r === 'updated') created.updated += 1;
else created.skipped += 1;
}
for (const p of allPreinvests) {
if (!str(p.project_name) || !str(p.apply_no)) continue;
const deleted = num(p.deleted) === 1;
const status = deleted ? 'TERMINATED' : mapPreinvestStatus(num(p.workflow_stage), num(p.approval_status));
if (!status) continue;
const r = await ensureBid({
bidNo: str(p.apply_no),
name: str(p.project_name),
official: officialNo(p.project_number),
status,
openAt: when(p.bid_open_time),
openPlace: clip(p.bid_open_location, 200),
applyStart: when(p.file_apply_time_start),
applyEnd: when(p.file_apply_time_end),
applyMethod: str(p.apply_method),
openMethod: str(p.bid_open_method),
projectType: str(p.screening_category || p.project_type),
source: str(p.project_source || p.entry_source),
qualification: str(p.qualification_require),
entityName: str(p.bid_entity),
createdAt: when(p.created_time),
createdByName: str(p.applicant_name),
tenderMethod: str(p.bid_method),
});
if (r === 'created') {
if (status === 'TERMINATED') created.terminated += 1;
else created.screening += 1;
} else if (r === 'updated') created.updated += 1;
else created.skipped += 1;
}
const itemsByReimb = new Map<string, Row[]>();
for (const item of flow.reimbursement_item || []) {
const key = String(item.reimbursement_id);
if (!itemsByReimb.has(key)) itemsByReimb.set(key, []);
itemsByReimb.get(key)!.push(item);
}
let expensesUpserted = 0;
for (const r of flow.reimbursement || []) {
const claimNo = str(r.reimburse_no);
if (!claimNo) continue;
const lines = itemsByReimb.get(String(r.id)) || [];
const main = [...lines].sort((a, b) => num(b.amount) - num(a.amount))[0];
const itemType = str(main?.item_type);
const applicantId = userByName.get(str(r.employee_name)) || admin.id;
const payerId = userByName.get(str(r.payer_name)) || undefined;
const projectId = r.project_id != null ? projectByOaId.get(String(r.project_id)) : projectByName.get(str(r.project_name));
const bidHit = findBid(str(r.project_name), []);
const bidCaseId = isBidExpense(itemType) || (!projectId && bidHit) ? bidHit?.id : undefined;
const costType = bidCaseId ? 'BID' : projectId ? 'PROJECT' : 'DEPT';
const data = {
applicantId,
costType,
budgetCategory: costType === 'PROJECT' ? 'TRAVEL' : undefined,
projectId: costType === 'PROJECT' ? projectId : undefined,
bidCaseId: costType === 'BID' ? bidCaseId : undefined,
expenseCategory: mapItemCategory(itemType),
purpose: clip(r.subject || r.project_name, 200) || undefined,
amount: new Prisma.Decimal(num(r.total_amount)),
status: mapExpenseStatus(r.approval_status),
remark: clip(r.remark, 1000) || undefined,
submittedAt: when(r.created_time) || undefined,
decidedAt: str(r.approval_status) === '2' ? when(r.updated_time) || when(r.created_time) : undefined,
decidedById: str(r.approval_status) === '2' ? admin.id : undefined,
prepaid: Boolean(payerId && payerId !== applicantId),
payerId: payerId && payerId !== applicantId ? payerId : undefined,
};
const existed = await prisma.expenseClaim.findFirst({ where: { tenantId: '1', claimNo } });
const row = existed
? await prisma.expenseClaim.update({ where: { id: existed.id }, data })
: await prisma.expenseClaim.create({ data: { claimNo, ...data } });
await prisma.expenseLine.deleteMany({ where: { claimId: row.id } });
for (const [i, line] of lines.entries()) {
await prisma.expenseLine.create({
data: {
claimId: row.id,
kind: mapItemKind(str(line.item_type), str(line.remark)),
amount: new Prisma.Decimal(num(line.amount)),
invoiceNo: clip(line.invoice_no, 80) || undefined,
occurAt: when(line.expense_date) || undefined,
remark: clip(line.remark || line.item_type, 200) || undefined,
sortNo: num(line.sort_order) || i,
},
});
}
expensesUpserted += 1;
}
let loansUpserted = 0;
for (const r of flow.borrow_advance || []) {
const loanNo = str(r.advance_no);
if (!loanNo) continue;
const applicantId = userByName.get(str(r.employee_name)) || admin.id;
const projectId = r.project_id != null ? projectByOaId.get(String(r.project_id)) : projectByName.get(str(r.project_name));
const data = {
applicantId,
costType: projectId ? 'PROJECT' : 'DEPT',
projectId,
amount: new Prisma.Decimal(num(r.amount)),
offsetAmount: new Prisma.Decimal(num(r.offset_amount)),
purpose: clip(r.reason || r.subject, 200) || 'OA 借款',
status: mapLoanStatus(r),
submittedAt: when(r.apply_time) || when(r.created_time) || undefined,
};
const existed = await prisma.loanRecord.findFirst({ where: { tenantId: '1', loanNo } });
if (existed) await prisma.loanRecord.update({ where: { id: existed.id }, data });
else await prisma.loanRecord.create({ data: { loanNo, ...data } });
loansUpserted += 1;
}
const bidCounts = await prisma.bidCase.groupBy({ by: ['status'], _count: true });
const expCounts = await prisma.expenseClaim.groupBy({ by: ['status'], _count: true });
const loanCounts = await prisma.loanRecord.groupBy({ by: ['status'], _count: true });
console.log(
JSON.stringify(
{ applyPatched, created, expensesUpserted, loansUpserted, bidCounts, expCounts, loanCounts },
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+563
View File
@@ -0,0 +1,563 @@
import { Prisma, PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_fresh.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 200) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s;
}
function realPartyName(raw: unknown) {
let name = str(raw).replace(/\s+/g, ' ');
if (!name) return '';
const alias: Record<string, string> = {
'73047': '中国人民解放军73047部队保障部',
'中国人民解放军 32272 部队': '中国人民解放军32272部队',
};
name = alias[name] || name;
if (/^未指定/.test(name)) return '';
if (/^\d+$/.test(name)) return '';
if (name.startsWith('/') || name.endsWith('.pdf')) return '';
if (name.length < 2) return '';
return name;
}
function compactName(name: string) {
return str(name)
.replace(/[()()\[\]【】\s\-—_.,,、]/g, '')
.replace(/招标公告|采购项目|技术服务合同|合同/g, '')
.replace(/检查院/g, '检察院');
}
function namesOverlap(a: string, b: string) {
const na = compactName(a);
const nb = compactName(b);
if (!na || !nb) return false;
if (na === nb) return true;
const ka = groupKey(a);
const kb = groupKey(b);
if (ka && ka === kb && ka.length >= 3 && ka !== na && ka !== nb) return true;
const shorter = na.length <= nb.length ? na : nb;
const longer = na.length <= nb.length ? nb : na;
return shorter.length >= 8 && longer.includes(shorter);
}
function groupKey(name: string) {
const anchors = [
'信息化教材管理服务平台',
'教务管理系统国产化',
'中船建模',
'课程录播资源管理平台',
'九江市国动办',
'北京顺义',
'天长市',
'国防科技大学试验训练',
'银川低空',
'无锡联勤',
'南京军代',
'日常办公(江苏',
'日常办公(南京',
'数字人',
'沉浸式体验区',
'某演练',
'某单位陈列室',
'OA网络教学',
'某部软件系统定制',
'院感及互联互通',
'财经管控',
'智能控制系统',
'某部门户网站',
];
const hit = anchors.find((a) => name.includes(a));
return hit || compactName(name) || name;
}
function mapBidStatus(row: Row) {
const st = str(row.project_status);
const win = str(row.winning_status);
if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON';
if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST';
if (st === '流标') return 'FAILED';
if (st === '弃标' || win.includes('弃标')) return 'TERMINATED';
return '';
}
function mapContractStatus(v: unknown) {
const s = str(v);
if (s === '已通过') return 'APPROVED';
if (s === '审批中') return 'PENDING';
return 'DRAFT';
}
function mapProjectStatus(v: unknown) {
const s = str(v);
if (s === '验收通过') return 'ACCEPTED';
if (s === '已完成') return 'DONE';
if (s === '待验收') return 'INTERNAL_ACCEPTED';
if (s === '未开始') return 'NOT_STARTED';
return 'ACTIVE';
}
const STATUS_RANK: Record<string, number> = {
验收通过: 5,
已完成: 4,
待验收: 3,
进行中: 2,
未开始: 1,
};
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function mapProjectType(v: unknown, projectName?: unknown) {
const name = str(projectName);
if (name.includes('三维')) return '三维';
if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制';
const s = str(v);
if (s.includes('三维')) return '三维';
if (s.includes('摄制') || s.includes('视频')) return '摄制';
if (s.includes('物资')) return '物资';
if (s.includes('集成')) return '集成';
return '软件';
}
function isRealTender(row: Row) {
return /^TB-\d/.test(str(row.system_number)) && Boolean(str(row.project_name)) && !str(row.project_name).startsWith('/profile/');
}
function isGenericContractName(name: string) {
return compactName(name) === '采购' || str(name) === '采购合同';
}
async function ensureParty(name: string, partyByName: Map<string, string>, remark: string) {
const existing = partyByName.get(name);
if (existing) return existing;
const row = await prisma.businessParty.create({ data: { name, remark, level: 'B' } });
partyByName.set(name, row.id);
return row.id;
}
type MergedProject = {
systemNumber: string;
name: string;
status: string;
oaStatus: string;
contractProdId: string;
contractName: string;
budget: number;
createdAt: Date | null;
};
function mergeProjects(rows: Row[]): MergedProject[] {
const groups = new Map<string, Row[]>();
for (const p of rows) {
const name = str(p.project_name);
if (!name || !str(p.system_number)) continue;
const key = groupKey(name);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(p);
}
const out: MergedProject[] = [];
for (const items of groups.values()) {
const withContract = items.find((p) => p.contract_id != null && str(p.contract_id));
const progressed = [...items].sort((a, b) => (STATUS_RANK[str(b.project_status)] || 0) - (STATUS_RANK[str(a.project_status)] || 0))[0];
const pick = withContract || progressed;
out.push({
systemNumber: str(pick.system_number),
name: str(progressed.project_name),
status: mapProjectStatus(progressed.project_status),
oaStatus: str(progressed.project_status),
contractProdId: withContract ? str(withContract.contract_id) : '',
contractName: str((withContract || pick).contract_name),
budget: Math.max(...items.map((p) => num(p.project_budget))),
createdAt: when(pick.create_time),
});
}
return out;
}
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('本地没有 admin');
const users = await prisma.user.findMany({ select: { id: true, displayName: true, username: true } });
const userByName = new Map(users.map((u) => [u.displayName, u.id]));
const xia = userByName.get('夏雨晴');
const entity =
(await prisma.legalEntity.findFirst({ where: { tenantId: '1', isDefault: true } })) ||
(await prisma.legalEntity.findFirst({ where: { tenantId: '1' } }));
if (!entity) throw new Error('本地没有主体');
const stale = await prisma.contract.findMany({
where: { contractNo: { startsWith: 'HT-2026-00' } },
include: {
_count: {
select: {
projects: true,
invoices: true,
bonds: true,
sealRequests: true,
milestones: true,
reviewers: true,
actionLogs: true,
},
},
},
});
const staleIds = stale.filter((c) => Object.values(c._count).every((n) => n === 0)).map((c) => c.id);
if (staleIds.length) await prisma.contract.deleteMany({ where: { id: { in: staleIds } } });
const tenders = (raw.oa_tender || []).filter(isRealTender);
const contracts = raw.oa_contract || [];
const mergedProjects = mergeProjects(raw.project_list || []);
const parties = await prisma.businessParty.findMany();
const partyByName = new Map(parties.map((p) => [p.name, p.id]));
for (const t of tenders) {
const n = realPartyName(t.tender_dan_wei);
if (n) await ensureParty(n, partyByName, 'OA 招标单位');
}
for (const c of contracts) {
const n = realPartyName(c.party_a_name);
if (n && n !== '江苏风影随行科技有限公司') await ensureParty(n, partyByName, 'OA 合同甲方');
}
const bids = await prisma.bidCase.findMany();
const bidByNo = new Map(bids.map((b) => [b.bidNo, b]));
let bidsUpdated = 0;
let bidsCreated = 0;
const usedNos = new Set(bids.map((b) => b.bidNo));
function findExistingBid(t: Row) {
const sys = str(t.system_number);
const official = officialNo(t.project_number);
const suffix = sys.split('-').pop() || '';
const name = str(t.project_name);
const all = [...bidByNo.values()];
return (
bidByNo.get(sys) ||
all.find((b) => b.externalNo === sys) ||
all.find((b) => b.bidNo === `${official}-${suffix}` || (official && b.bidNo.endsWith(`-${suffix}`) && b.name === name)) ||
(official && all.filter((b) => b.bidNo === official || b.externalNo === official).length === 1
? all.find((b) => b.bidNo === official || b.externalNo === official)
: undefined)
);
}
const testNos = ['SP-TEST-1788068192', 'SP-FIX-1788068273'];
const tests = bids.filter((b) => testNos.includes(b.bidNo) || testNos.includes(b.externalNo || ''));
if (tests.length) {
const ids = tests.map((b) => b.id);
await prisma.todoTask.deleteMany({ where: { bizId: { in: ids } } });
await prisma.approvalTask.deleteMany({ where: { bizId: { in: ids } } });
await prisma.bidCase.deleteMany({ where: { id: { in: ids } } });
}
for (const t of tenders) {
const sys = str(t.system_number);
const official = officialNo(t.project_number);
const name = clip(t.project_name, 200);
const partyName = realPartyName(t.tender_dan_wei);
const status = mapBidStatus(t) || 'PENDING';
const existing = findExistingBid(t);
const resultBits = [
t.winning_unit ? `中标单位:${t.winning_unit}` : '',
num(t.winning_amount) ? `中标金额:${num(t.winning_amount)}` : '',
str(t.opening_remark),
str(t.bid_summary),
].filter(Boolean);
if (existing) {
let bidNo = official || sys;
if (usedNos.has(bidNo) && bidNo !== existing.bidNo) bidNo = `${official || sys}-${sys.slice(-3)}`;
usedNos.delete(existing.bidNo);
usedNos.add(bidNo);
await prisma.bidCase.update({
where: { id: existing.id },
data: {
bidNo,
name,
externalNo: official || null,
status,
sourceType: 'FLOW',
partyId: partyName ? partyByName.get(partyName) : existing.partyId,
tenderMethod: clip(t.tender_method, 50) || existing.tenderMethod,
openPlace: clip(t.tender_address, 200) || existing.openPlace,
priceCap: t.price_limit == null ? existing.priceCap : String(t.price_limit),
quoteAmount: t.bid_quotation == null ? existing.quoteAmount : String(t.bid_quotation),
resultNote: resultBits.join('') || existing.resultNote,
openedResultAt: when(t.winning_date) || when(t.bid_open_date) || existing.openedResultAt,
},
});
existing.bidNo = bidNo;
existing.name = name;
existing.status = status;
existing.externalNo = official || existing.externalNo;
bidByNo.set(bidNo, existing);
bidByNo.set(sys, existing);
bidsUpdated += 1;
} else {
let bidNo = official || sys;
if (usedNos.has(bidNo)) bidNo = `${sys}`;
if (usedNos.has(bidNo)) bidNo = `${sys}-${Date.now().toString().slice(-4)}`;
usedNos.add(bidNo);
const row = await prisma.bidCase.create({
data: {
bidNo,
name,
externalNo: official || undefined,
projectType: mapProjectType(t.project_type, t.project_name),
legalEntityId: entity.id,
partyId: partyName ? partyByName.get(partyName) : undefined,
tenderMethod: clip(t.tender_method, 50) || undefined,
applyEndAt: when(t.file_apply_deadline) || undefined,
openAt: when(t.bid_open_date) || undefined,
openPlace: clip(t.tender_address, 200) || undefined,
securityLevel: str(t.security_level) || 'INTERNAL',
projectNature: clip(t.project_nature, 50) || undefined,
contactName: clip(t.bid_contact_person, 64) || undefined,
contactPhone: clip(t.bid_contact_phone, 32) || undefined,
priceCap: t.price_limit == null ? undefined : String(t.price_limit),
quoteAmount: t.bid_quotation == null ? undefined : String(t.bid_quotation),
resultNote: resultBits.join('') || undefined,
openedResultAt: when(t.winning_date) || when(t.bid_open_date) || undefined,
status,
createdById: admin.id,
createdAt: when(t.create_time) || undefined,
},
});
bidByNo.set(bidNo, row);
bidByNo.set(sys, row);
bidsCreated += 1;
}
}
const localContracts = await prisma.contract.findMany();
const contractByNo = new Map(localContracts.map((c) => [c.contractNo, c]));
const contractByProd = new Map<string, string>();
let contractsUpdated = 0;
let contractsCreated = 0;
for (const c of contracts) {
const contractNo = str(c.contract_no);
if (!contractNo) continue;
const tb = contractNo.match(/TB-\d+-\d+/);
const bid =
(tb && (bidByNo.get(tb[0]) || [...bidByNo.values()].find((b) => b.bidNo === tb[0] || b.bidNo.startsWith(tb[0])))) ||
[...bidByNo.values()].find((b) => namesOverlap(b.name, str(c.contract_name)));
if (str(c.approval_status) === 'pending_submit' && !c.sign_date && !num(c.amount)) {
continue;
}
const partyA = realPartyName(c.party_a_name);
const partyB = str(c.party_b_name);
const remark = [
str(c.payment_method) ? `付款:${c.payment_method}` : '',
str(c.contract_lb) ? `类别:${c.contract_lb}` : '',
str(c.remark),
]
.filter(Boolean)
.join(' / ');
const handlerId = Number(c.create_by) === 107 ? xia : bid?.createdById || xia || admin.id;
const data = {
name: clip(c.contract_name, 200),
bidCaseId: bid?.id,
legalEntityId: entity.id,
amount: new Prisma.Decimal(num(c.amount)),
status: mapContractStatus(c.approval_status),
partyA: partyA || undefined,
partyB: partyB || undefined,
remark: remark || undefined,
signedAt: when(c.sign_date) || undefined,
createdById: handlerId,
};
const existing =
contractByNo.get(contractNo) ||
localContracts.find(
(row) => row.name === clip(c.contract_name, 200) && /^HT-2026-00\d+$/.test(row.contractNo),
);
if (existing) {
const nextNo = contractByNo.has(contractNo) && existing.contractNo !== contractNo ? existing.contractNo : contractNo;
await prisma.contract.update({
where: { id: existing.id },
data: { ...data, contractNo: nextNo },
});
existing.contractNo = nextNo;
contractByNo.set(nextNo, existing);
contractByProd.set(String(c.id), existing.id);
contractsUpdated += 1;
} else {
const row = await prisma.contract.create({
data: {
contractNo,
createdAt: when(c.create_time) || undefined,
...data,
},
});
contractByNo.set(contractNo, row);
contractByProd.set(String(c.id), row.id);
contractsCreated += 1;
}
}
const localProjects = await prisma.project.findMany({ include: { budgets: true } });
const usedProjectNos = new Set(localProjects.map((p) => p.projectNo));
let projectsUpdated = 0;
let projectsCreated = 0;
const projectMoves: { name: string; from: string; to: string; no: string }[] = [];
function findLocalProject(p: MergedProject) {
return (
localProjects.find((row) => row.projectNo === p.systemNumber) ||
localProjects.find((row) => namesOverlap(row.name, p.name) || groupKey(row.name) === groupKey(p.name))
);
}
function findContractId(p: MergedProject) {
if (p.contractProdId && contractByProd.has(p.contractProdId)) return contractByProd.get(p.contractProdId);
if (p.contractName) {
const exact = [...contractByNo.values()].find((c) => c.name === p.contractName);
if (exact) return exact.id;
}
const fuzzy = [...contractByNo.values()].find(
(c) => !isGenericContractName(c.name) && namesOverlap(c.name, p.name),
);
return fuzzy?.id;
}
for (const p of mergedProjects) {
const existing = findLocalProject(p);
const contractId = findContractId(p);
if (existing) {
let projectNo = p.systemNumber;
if (usedProjectNos.has(projectNo) && projectNo !== existing.projectNo) {
projectNo = existing.projectNo;
}
usedProjectNos.delete(existing.projectNo);
usedProjectNos.add(projectNo);
if (existing.status !== p.status || existing.projectNo !== projectNo) {
projectMoves.push({ name: p.name.slice(0, 24), from: `${existing.projectNo}/${existing.status}`, to: `${projectNo}/${p.status}`, no: p.oaStatus });
}
await prisma.project.update({
where: { id: existing.id },
data: {
projectNo,
name: clip(p.name, 200),
contractId,
status: p.status,
},
});
if (p.budget > 0) {
await prisma.projectBudget.upsert({
where: { projectId_category: { projectId: existing.id, category: 'PURCHASE' } },
update: { amount: new Prisma.Decimal(p.budget) },
create: { projectId: existing.id, category: 'PURCHASE', amount: new Prisma.Decimal(p.budget) },
});
}
existing.projectNo = projectNo;
existing.name = p.name;
existing.status = p.status;
projectsUpdated += 1;
} else {
if (!contractId && /招标公告/.test(p.name) && p.status === 'NOT_STARTED') {
continue;
}
let projectNo = p.systemNumber;
if (usedProjectNos.has(projectNo)) projectNo = `${p.systemNumber}-2`;
usedProjectNos.add(projectNo);
const row = await prisma.project.create({
data: {
projectNo,
name: clip(p.name, 200),
contractId,
status: p.status,
createdAt: p.createdAt || undefined,
},
});
if (p.budget > 0) {
await prisma.projectBudget.create({
data: { projectId: row.id, category: 'PURCHASE', amount: new Prisma.Decimal(p.budget) },
});
}
localProjects.push({ ...row, budgets: [] });
projectsCreated += 1;
}
}
const caijing = await prisma.bidCase.findMany({
where: { name: { contains: '财经管控' } },
include: { _count: { select: { contracts: true, expenses: true, bonds: true, assignees: true, versions: true } } },
});
if (caijing.length) {
const won = caijing.find((b) => /196/.test(b.bidNo) || b._count.contracts > 0);
const extras = caijing.filter((b) => b.id !== won?.id);
const keepFailed = extras.find((b) => Object.values(b._count).some((n) => n > 0)) || extras[0];
if (keepFailed) {
await prisma.bidCase.update({
where: { id: keepFailed.id },
data: { status: 'FAILED', bidNo: keepFailed.bidNo.includes('182') ? keepFailed.bidNo : '2026-JLDJAE-W3006-182' },
});
}
if (won && won.status !== 'WON') {
await prisma.bidCase.update({ where: { id: won.id }, data: { status: 'WON' } });
}
const drop = extras.filter((b) => b.id !== keepFailed?.id && Object.values(b._count).every((n) => n === 0)).map((b) => b.id);
if (drop.length) await prisma.bidCase.deleteMany({ where: { id: { in: drop } } });
}
const bidCounts = await prisma.bidCase.groupBy({ by: ['status'], _count: true });
const projectCounts = await prisma.project.groupBy({ by: ['status'], _count: true });
const linked = await prisma.project.count({ where: { contractId: { not: null } } });
console.log(
JSON.stringify(
{
tenders: tenders.length,
bidsUpdated,
bidsCreated,
contractsUpdated,
contractsCreated,
projectsMerged: mergedProjects.length,
projectsUpdated,
projectsCreated,
projectMoves,
bidCounts,
projectCounts,
projectsWithContract: linked,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+186
View File
@@ -0,0 +1,186 @@
import { Prisma, PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_flow_finance.json';
const APPLY = process.argv.includes('--apply');
type Row = Record<string, unknown>;
type Bid = {
id: string;
bidNo: string;
name: string;
externalNo: string | null;
status: string;
winnerName: string | null;
winnerAmount: Prisma.Decimal | null;
winnerNote: string | null;
};
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return null;
const n = Number(String(v).replace(/,/g, ''));
if (!Number.isFinite(n) || n === 0) return null;
return n;
}
function compactName(name: string) {
return str(name)
.replace(/\s+/g, '')
.replace(/[(].*?[)]/g, '')
.replace(/招标公告|采购公告|询价公告|竞争性谈判公告|谈判公告|比价公告|需求公示|意向公开/g, '');
}
function isOwnWinner(name: string) {
return /风影|随行科技|山东鲁兵/.test(name) || name === '隆创';
}
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function pickUnused(cands: Bid[], used: Set<string>) {
const open = cands.filter((b) => !used.has(b.id));
if (!open.length) return null;
const yt = open.find((b) => b.bidNo.startsWith('YT-'));
return yt || open[0];
}
async function main() {
if (!fs.existsSync(DUMP)) {
throw new Error(`找不到 OA 备份 ${DUMP}`);
}
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const pres = raw.project_preinvest_apply || [];
const sources = pres.filter((p) => {
const deleted = str(p.deleted) === '1';
const winner = str(p.winning_unit);
const cat = str(p.screening_category) || str(p.project_type);
return deleted && winner && cat.includes('软件');
});
const bids = await prisma.bidCase.findMany({
where: { tenantId: '1', status: 'TERMINATED' },
select: {
id: true,
bidNo: true,
name: true,
externalNo: true,
status: true,
winnerName: true,
winnerAmount: true,
winnerNote: true,
},
});
const byNo = new Map<string, Bid[]>();
const byName = new Map<string, Bid[]>();
const byCompact = new Map<string, Bid[]>();
const add = (map: Map<string, Bid[]>, key: string, row: Bid) => {
if (!key) return;
const list = map.get(key) || [];
list.push(row);
map.set(key, list);
};
for (const b of bids) {
add(byNo, b.bidNo, b);
add(byNo, str(b.externalNo), b);
add(byName, b.name, b);
add(byCompact, compactName(b.name), b);
}
const used = new Set<string>();
const updated: { bidNo: string; name: string; winner: string; amount: number | null; via: string }[] = [];
const skippedSame: string[] = [];
const conflicts: { bidNo: string; have: string; want: string }[] = [];
const unmatched: { applyNo: string; name: string; winner: string }[] = [];
const ownSkipped: string[] = [];
for (const p of sources) {
const applyNo = str(p.apply_no);
const name = str(p.project_name);
const official = officialNo(p.project_number);
const winner = str(p.winning_unit);
const amount = num(p.winning_amount);
if (isOwnWinner(winner)) {
ownSkipped.push(`${applyNo} ${winner}`);
continue;
}
const hit =
pickUnused(byNo.get(applyNo) || [], used) ||
pickUnused(byNo.get(official) || [], used) ||
pickUnused(byName.get(name) || [], used) ||
pickUnused(byCompact.get(compactName(name)) || [], used);
if (!hit) {
unmatched.push({ applyNo, name, winner });
continue;
}
used.add(hit.id);
const haveName = str(hit.winnerName);
const haveAmt = hit.winnerAmount != null ? Number(hit.winnerAmount) : null;
if (haveName) {
const sameName = haveName === winner;
const sameAmt = amount == null || haveAmt === amount;
if (sameName && sameAmt) skippedSame.push(hit.bidNo);
else conflicts.push({ bidNo: hit.bidNo, have: `${haveName}/${haveAmt}`, want: `${winner}/${amount}` });
continue;
}
const note = str(hit.winnerNote) || 'OA 软件已删除迁入';
if (APPLY) {
await prisma.bidCase.update({
where: { id: hit.id },
data: {
winnerName: winner,
winnerAmount: amount == null ? undefined : new Prisma.Decimal(amount),
winnerNote: note,
},
});
}
updated.push({
bidNo: hit.bidNo,
name: hit.name,
winner,
amount,
via: hit.bidNo === applyNo ? 'bidNo' : str(hit.externalNo) === applyNo || hit.bidNo === official ? 'official' : 'name',
});
}
console.log(
JSON.stringify(
{
apply: APPLY,
dumpSources: sources.length,
terminated: bids.length,
filled: updated.length,
skippedSame: skippedSame.length,
conflicts: conflicts.length,
unmatched: unmatched.length,
ownSkipped: ownSkipped.length,
filledSample: updated.slice(0, 8),
conflictRows: conflicts,
unmatchedRows: unmatched.slice(0, 20),
tinyAmounts: updated.filter((u) => u.amount != null && u.amount < 100),
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+93
View File
@@ -0,0 +1,93 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { loadEnv } from './config/env';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { SmsModule } from './sms/sms.module';
import { ImModule } from './im/im.module';
import { PushModule } from './push/push.module';
import { HealthController } from './health/health.controller';
import { ReportController } from './report/report.controller';
import { ReportService } from './report/report.service';
import { SystemController } from './system/system.controller';
import { SystemService } from './system/system.service';
import { OfficeController } from './office/office.controller';
import { MasterController } from './master/master.controller';
import { BiddingController } from './bidding/bidding.controller';
import { BiddingService } from './bidding/bidding.service';
import { ContractController } from './contract/contract.controller';
import { ContractService } from './contract/contract.service';
import { ProjectController } from './project/project.controller';
import { ProjectService } from './project/project.service';
import { SealController } from './seal/seal.controller';
import { SealService } from './seal/seal.service';
import { FinanceController } from './finance/finance.controller';
import { FinanceService } from './finance/finance.service';
import { BusinessController } from './business/business.controller';
import { HrController } from './hr/hr.controller';
import { HrService } from './hr/hr.service';
import { MasterService } from './master/master.service';
import { FilesController } from './files/files.controller';
import { FilesModule } from './files/files.module';
import { JwtAuthGuard, PermissionsGuard } from './common/guards/auth.guards';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { HttpErrorFilter } from './common/filters/http-exception.filter';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { LegalController } from './legal/legal.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env', '../../.env'],
validate: (raw) => loadEnv(raw),
}),
ThrottlerModule.forRoot({
throttlers: [{ ttl: 60000, limit: 120 }],
}),
PrismaModule,
AuthModule,
SmsModule,
PushModule,
ImModule,
FilesModule,
],
controllers: [
HealthController,
SystemController,
OfficeController,
MasterController,
BiddingController,
ContractController,
ProjectController,
SealController,
FinanceController,
BusinessController,
HrController,
ReportController,
LegalController,
],
providers: [
BiddingService,
ContractService,
ProjectService,
SealService,
FinanceService,
HrService,
ReportService,
SystemService,
MasterService,
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
{ provide: APP_FILTER, useClass: HttpErrorFilter },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
+199
View File
@@ -0,0 +1,199 @@
import { BadRequestException, Body, Controller, Get, Patch, Post, Query, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { Equals, IsOptional, IsString, MinLength } from 'class-validator';
import type { Request } from 'express';
import { AuthService } from './auth.service';
import { Public } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthUser } from '../common/decorators/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import { LOG_ACTION, writeOpLog } from '../system/op-log';
import { REQUEST_ID_HEADER } from '../common/middleware/request-id.middleware';
class LoginDto {
@IsString()
username!: string;
@IsString()
@MinLength(6)
password!: string;
@IsOptional()
@IsString()
smsCode?: string;
@Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1')
@Equals(true, { message: '请先阅读并同意用户协议和隐私政策' })
agreeTerms!: boolean;
@IsOptional()
@IsString()
clientKind?: string;
}
class RefreshDto {
@IsString()
refreshToken!: string;
@IsOptional()
@IsString()
clientKind?: string;
}
class ProfileDto {
@IsOptional()
@IsString()
displayName?: string;
}
class PasswordDto {
@IsString()
oldPassword!: string;
@IsString()
@MinLength(6, { message: '新密码至少 6 位' })
newPassword!: string;
}
class OnboardProfileDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() gender?: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() mobile?: string;
@IsOptional() @IsString() birthday?: string;
@IsOptional() @IsString() ethnicity?: string;
@IsOptional() @IsString() maritalStatus?: string;
@IsOptional() @IsString() hukouAddress?: string;
@IsOptional() @IsString() liveAddress?: string;
@IsOptional() @IsString() emergencyName?: string;
@IsOptional() @IsString() emergencyPhone?: string;
@IsOptional() @IsString() education?: string;
@IsOptional() @IsString() school?: string;
@IsOptional() @IsString() major?: string;
@IsOptional() @IsString() graduatedAt?: string;
@IsOptional() @IsString() bankName?: string;
@IsOptional() @IsString() bankAccount?: string;
@IsOptional() @IsString() idType?: string;
@IsOptional() @IsString() idNo?: string;
@IsOptional() @IsString() idIssuer?: string;
@IsOptional() @IsString() idValidFrom?: string;
@IsOptional() @IsString() idValidTo?: string;
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly prisma: PrismaService,
private readonly sms: SmsService,
) {}
@Public()
@Get('login-options')
loginOptions() {
return this.sms.loginOptions();
}
@Public()
@Post('sms-code')
smsCode(@Body() body: { username?: string }) {
return this.sms.sendLoginCode(body.username || '');
}
@Public()
@Post('login')
@ApiOperation({ summary: '登录' })
async login(@Body() dto: LoginDto, @Req() req: Request) {
const requestId = (req.headers[REQUEST_ID_HEADER] as string) || '';
try {
if (!dto.agreeTerms) {
throw new BadRequestException('请先阅读并同意用户协议和隐私政策');
}
const result = await this.auth.login(dto.username, dto.password, dto.smsCode, dto.clientKind);
await writeOpLog(this.prisma, {
userId: result.user.id,
username: result.user.username,
action: LOG_ACTION.LOGIN,
path: '/auth/login',
detail: '登录成功',
requestId,
ip: req.ip,
});
return result;
} catch (e) {
await writeOpLog(this.prisma, {
username: dto.username,
action: LOG_ACTION.LOGIN_FAIL,
path: '/auth/login',
detail: '用户名或密码错误',
success: false,
requestId,
ip: req.ip,
});
throw e;
}
}
@Public()
@Post('refresh')
@ApiOperation({ summary: '刷新令牌' })
refresh(@Body() dto: RefreshDto) {
return this.auth.refresh(dto.refreshToken, dto.clientKind);
}
@Post('logout')
@ApiBearerAuth()
logout(@CurrentUser() user: AuthUser, @Body() dto: Partial<RefreshDto>) {
return this.auth.logout(user.id, dto.refreshToken);
}
@Public()
@Post('qr/start')
qrStart() {
return this.auth.startQrLogin();
}
@Public()
@Get('qr/status')
qrStatus(@Query('ticket') ticket?: string) {
return this.auth.qrStatus(ticket || '');
}
@Public()
@Post('qr/complete')
qrComplete(@Body() body: { ticket?: string; code?: string }) {
return this.auth.completeQrLogin(body.ticket || '', body.code || '');
}
@Post('qr/confirm')
@ApiBearerAuth()
qrConfirm(@CurrentUser() user: AuthUser, @Body() body: { ticket?: string }) {
return this.auth.confirmQrLogin(user.id, body.ticket || '');
}
@Get('me')
@ApiBearerAuth()
me(@CurrentUser() user: AuthUser) {
return this.auth.me(user.id);
}
@Patch('profile')
@ApiBearerAuth()
updateProfile(@CurrentUser() user: AuthUser, @Body() dto: ProfileDto) {
return this.auth.updateProfile(user.id, dto);
}
@Patch('password')
@ApiBearerAuth()
updatePassword(@CurrentUser() user: AuthUser, @Body() dto: PasswordDto) {
return this.auth.updatePassword(user.id, dto);
}
@Patch('onboard-profile')
@ApiBearerAuth()
onboardProfile(@CurrentUser() user: AuthUser, @Body() dto: OnboardProfileDto) {
return this.auth.onboardProfile(user.id, dto);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [PassportModule, JwtModule.register({})],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+532
View File
@@ -0,0 +1,532 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { createHash, randomInt, randomUUID } from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
import { SmsService } from '../sms/sms.service';
import { elevateToAdmin } from '../common/role-groups';
function sha256(value: string) {
return createHash('sha256').update(value).digest('hex');
}
function needsOnboardGate(input: {
isAdmin: boolean;
mustChangePassword?: boolean | null;
profileCompletedAt?: Date | string | null;
employeeId?: string | null;
idPhotoFileId?: string | null;
idFrontFileId?: string | null;
idBackFileId?: string | null;
employee?: { idNo?: string | null; idType?: string | null } | null;
}) {
if (input.isAdmin) return false;
if (input.mustChangePassword) return true;
if (!input.profileCompletedAt) return true;
if (!input.employeeId) return false;
if (!input.idPhotoFileId || !input.idFrontFileId || !input.idBackFileId) return true;
if (input.employee && (!input.employee.idNo || !input.employee.idType)) return true;
return false;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
) {}
async login(username: string, password: string, smsCode?: string, clientKind = 'web') {
const account = username.trim();
const kind = normalizeClientKind(clientKind);
if (kind === 'desktop') {
throw new BadRequestException('电脑端请用手机扫码登录');
}
const user = await this.prisma.user.findFirst({
where: {
tenantId: '1',
OR: [{ username: account }, { employee: { is: { mobile: account } } }],
},
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: { include: { department: true } },
},
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('手机号或密码错误');
}
const ok = await argon2.verify(user.passwordHash, password);
if (!ok) {
throw new UnauthorizedException('用户名或密码错误');
}
await this.sms.assertLoginCode(account, smsCode);
return this.issueTokens(user, kind);
}
async refresh(refreshToken: string, clientKindHint?: string) {
if (!refreshToken) throw new BadRequestException('缺少刷新令牌');
let payload: { sub: string; typ: string; ck?: string };
try {
payload = await this.jwt.verifyAsync(refreshToken, {
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
});
} catch {
throw new UnauthorizedException('刷新令牌无效');
}
if (payload.typ !== 'refresh') {
throw new UnauthorizedException('刷新令牌无效');
}
const hash = sha256(refreshToken);
const stored = await this.prisma.refreshToken.findFirst({
where: { userId: payload.sub, tokenHash: hash, revokedAt: null },
});
if (!stored || stored.expiresAt < new Date()) {
throw new UnauthorizedException('刷新令牌已失效');
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: { include: { department: true } },
},
});
if (!user) throw new UnauthorizedException('用户不存在');
const kind = normalizeClientKind(clientKindHint || stored.clientKind || payload.ck);
if (stored.clientKind !== kind) {
await this.prisma.refreshToken.update({ where: { id: stored.id }, data: { clientKind: kind } });
}
if (kind === 'mobile') {
return this.issueTokens(user, kind, { keepRefresh: stored });
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
return this.issueTokens(user, kind);
}
async logout(userId: string, refreshToken?: string) {
if (refreshToken) {
await this.prisma.refreshToken.updateMany({
where: { userId, tokenHash: sha256(refreshToken), revokedAt: null },
data: { revokedAt: new Date() },
});
} else {
await this.prisma.refreshToken.updateMany({
where: { userId, clientKind: { in: ['web', 'desktop'] }, revokedAt: null },
data: { revokedAt: new Date() },
});
}
return { ok: true };
}
async updateProfile(userId: string, dto: { displayName?: string }) {
const name = dto.displayName?.trim();
if (!name) throw new BadRequestException('请填写姓名');
const user = await this.prisma.user.update({
where: { id: userId },
data: { displayName: name },
include: { employee: true },
});
if (user.employeeId) {
await this.prisma.employee.update({
where: { id: user.employeeId },
data: { name },
});
}
return this.me(userId);
}
async updatePassword(userId: string, dto: { oldPassword: string; newPassword: string }) {
if (!dto.newPassword || dto.newPassword.length < 6) {
throw new BadRequestException('新密码至少 6 位');
}
const user = await this.prisma.user.findFirst({ where: { id: userId, tenantId: '1' } });
if (!user) throw new UnauthorizedException();
const ok = await argon2.verify(user.passwordHash, dto.oldPassword || '');
if (!ok) throw new BadRequestException('当前密码不正确');
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: await argon2.hash(dto.newPassword), mustChangePassword: false },
});
return { ok: true };
}
async onboardProfile(
userId: string,
dto: {
name?: string;
gender?: string;
email?: string;
mobile?: string;
birthday?: string;
ethnicity?: string;
maritalStatus?: string;
hukouAddress?: string;
liveAddress?: string;
emergencyName?: string;
emergencyPhone?: string;
education?: string;
school?: string;
major?: string;
graduatedAt?: string;
bankName?: string;
bankAccount?: string;
idType?: string;
idNo?: string;
idIssuer?: string;
idValidFrom?: string;
idValidTo?: string;
},
) {
const user = await this.prisma.user.findFirst({
where: { id: userId, tenantId: '1' },
include: { roles: { include: { role: true } }, employee: true },
});
if (!user) throw new UnauthorizedException();
const isAdmin = user.roles.some((r) => r.role.code === 'admin');
if (isAdmin) return this.me(userId);
if (user.employeeId && user.employee) {
const name = dto.name?.trim() || user.employee.name;
if (!name) throw new BadRequestException('请填写姓名');
if (!dto.mobile?.trim() && !user.employee.mobile) throw new BadRequestException('请填写手机号码');
if (!dto.idType?.trim() && !user.employee.idType) throw new BadRequestException('请选择证件类型');
if (!dto.idNo?.trim() && !user.employee.idNo) throw new BadRequestException('请填写证件号码');
if (!dto.idIssuer?.trim() && !user.employee.idIssuer) throw new BadRequestException('请填写发证机关');
if (!dto.idValidFrom && !user.employee.idValidFrom) throw new BadRequestException('请选择证件有效期起');
if (!dto.idValidTo && !user.employee.idValidTo) throw new BadRequestException('请选择证件有效期止');
if (!dto.education?.trim() && !user.employee.education) throw new BadRequestException('请填写学历');
if (!dto.bankName?.trim() && !user.employee.bankName) throw new BadRequestException('请填写南京银行卡开户行');
if (!dto.bankAccount?.trim() && !user.employee.bankAccount) throw new BadRequestException('请填写南京银行卡号');
const scans = await this.prisma.fileAsset.findMany({
where: {
tenantId: '1',
OR: [
{ bizType: 'USER_ID_PHOTO', bizId: userId },
{ bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] }, bizId: user.employeeId },
],
},
});
const hasPhoto = scans.some((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO');
const hasFront = scans.some((f) => f.bizType === 'EMPLOYEE_ID_FRONT');
const hasBack = scans.some((f) => f.bizType === 'EMPLOYEE_ID_BACK');
if (!hasPhoto) throw new BadRequestException('请上传证件照');
if (!hasFront) throw new BadRequestException('请上传身份证正面');
if (!hasBack) throw new BadRequestException('请上传身份证反面');
await this.prisma.employee.update({
where: { id: user.employeeId },
data: {
name,
gender: dto.gender?.trim() || user.employee.gender,
email: dto.email?.trim() || user.employee.email,
mobile: dto.mobile?.trim() || user.employee.mobile,
birthday: dto.birthday ? new Date(dto.birthday) : user.employee.birthday,
ethnicity: dto.ethnicity?.trim() || user.employee.ethnicity,
maritalStatus: dto.maritalStatus?.trim() || user.employee.maritalStatus,
hukouAddress: dto.hukouAddress?.trim() || user.employee.hukouAddress,
liveAddress: dto.liveAddress?.trim() || user.employee.liveAddress,
emergencyName: dto.emergencyName?.trim() || user.employee.emergencyName,
emergencyPhone: dto.emergencyPhone?.trim() || user.employee.emergencyPhone,
idType: dto.idType?.trim() || user.employee.idType,
idNo: dto.idNo?.trim() || user.employee.idNo,
idIssuer: dto.idIssuer?.trim() || user.employee.idIssuer,
idValidFrom: dto.idValidFrom ? new Date(dto.idValidFrom) : user.employee.idValidFrom,
idValidTo: dto.idValidTo ? new Date(dto.idValidTo) : user.employee.idValidTo,
education: dto.education?.trim() || user.employee.education,
school: dto.school?.trim() || user.employee.school,
major: dto.major?.trim() || user.employee.major,
graduatedAt: dto.graduatedAt ? new Date(dto.graduatedAt) : user.employee.graduatedAt,
bankName: dto.bankName?.trim() || user.employee.bankName,
bankAccount: dto.bankAccount?.trim() || user.employee.bankAccount,
},
});
if (name !== user.displayName) {
await this.prisma.user.update({ where: { id: userId }, data: { displayName: name } });
}
}
await this.prisma.user.update({
where: { id: userId },
data: { profileCompletedAt: new Date() },
});
return this.me(userId);
}
async me(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: { include: { department: true } },
},
});
if (!user) throw new UnauthorizedException();
const roles = elevateToAdmin(user.roles.map((r) => r.role.code));
const permissions = [
...new Set(user.roles.flatMap((r) => r.role.permissions.map((p) => p.permission.code))),
];
const photos = await this.prisma.fileAsset.findMany({
where: {
tenantId: '1',
OR: [
{ bizId: user.id, bizType: { in: ['USER_AVATAR', 'USER_ID_PHOTO'] } },
user.employeeId
? { bizId: user.employeeId, bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] } }
: undefined,
].filter(Boolean) as Prisma.FileAssetWhereInput[],
},
orderBy: { createdAt: 'desc' },
});
const isAdmin = roles.includes('admin');
const idPhotoFileId =
photos.find((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO')?.id || null;
const idFrontFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_FRONT')?.id || null;
const idBackFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_BACK')?.id || null;
return {
id: user.id,
username: user.username,
displayName: user.displayName,
avatarFileId: photos.find((f) => f.bizType === 'USER_AVATAR')?.id || null,
idPhotoFileId,
idFrontFileId,
idBackFileId,
roles,
permissions,
employeeId: user.employeeId,
mustChangePassword: isAdmin ? false : user.mustChangePassword,
profileCompletedAt: user.profileCompletedAt,
needsOnboard: needsOnboardGate({
isAdmin,
mustChangePassword: user.mustChangePassword,
profileCompletedAt: user.profileCompletedAt,
employeeId: user.employeeId,
idPhotoFileId,
idFrontFileId,
idBackFileId,
employee: user.employee,
}),
dataScope: isAdmin || roles.includes('owner')
? 'ALL'
: user.roles.reduce(
(best, r) => {
const rank = { SELF: 0, DEPT: 1, ALL: 2 } as Record<string, number>;
return (rank[r.role.dataScope] || 0) > (rank[best] || 0) ? r.role.dataScope : best;
},
'SELF',
),
employee: user.employee,
};
}
private async issueTokens(
user: {
id: string;
username: string;
displayName: string;
employeeId?: string | null;
mustChangePassword?: boolean;
profileCompletedAt?: Date | null;
roles: { role: { code: string; permissions: { permission: { code: string; permType: string; path: string | null; name: string; icon: string | null; parentId: string | null; sortNo: number; visible: boolean; id: string } }[] } }[];
employee: unknown;
},
clientKind: ClientKind,
opts?: { keepRefresh?: { id: string; tokenHash: string; expiresAt: Date } },
) {
if (clientKind === 'web' || clientKind === 'desktop') {
await this.prisma.refreshToken.updateMany({
where: { userId: user.id, clientKind, revokedAt: null },
data: { revokedAt: new Date() },
});
}
const accessTtl = clientKind === 'mobile' ? '7d' : '12h';
const refreshTtl = clientKind === 'mobile' ? '3650d' : '2d';
const sessionId = opts?.keepRefresh?.id || randomUUID();
const accessToken = await this.jwt.signAsync(
{ sub: user.id, username: user.username, typ: 'access', sid: sessionId, ck: clientKind },
{
secret: this.config.get<string>('JWT_ACCESS_SECRET'),
expiresIn: accessTtl,
},
);
let refreshToken = '';
if (opts?.keepRefresh) {
refreshToken = '';
} else {
refreshToken = await this.jwt.signAsync(
{ sub: user.id, typ: 'refresh', jti: randomUUID(), ck: clientKind },
{
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
expiresIn: refreshTtl,
},
);
const days = clientKind === 'mobile' ? 3650 : 2;
await this.prisma.refreshToken.create({
data: {
id: sessionId,
userId: user.id,
tokenHash: sha256(refreshToken),
clientKind,
expiresAt: new Date(Date.now() + days * 24 * 3600 * 1000),
},
});
}
const permissions = [
...new Set(
user.roles.flatMap((r) => r.role.permissions.map((p) => p.permission)),
),
];
const photos = await this.prisma.fileAsset.findMany({
where: {
tenantId: '1',
OR: [
{ bizId: user.id, bizType: { in: ['USER_AVATAR', 'USER_ID_PHOTO'] } },
user.employeeId
? { bizId: user.employeeId, bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] } }
: undefined,
].filter(Boolean) as Prisma.FileAssetWhereInput[],
},
orderBy: { createdAt: 'desc' },
});
const roles = elevateToAdmin(user.roles.map((r) => r.role.code));
const isAdmin = roles.includes('admin');
const idPhotoFileId =
photos.find((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO')?.id || null;
const idFrontFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_FRONT')?.id || null;
const idBackFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_BACK')?.id || null;
return {
accessToken,
refreshToken: refreshToken || undefined,
clientKind,
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
avatarFileId: photos.find((f) => f.bizType === 'USER_AVATAR')?.id || null,
idPhotoFileId,
idFrontFileId,
idBackFileId,
roles,
employeeId: user.employeeId,
mustChangePassword: isAdmin ? false : Boolean(user.mustChangePassword),
profileCompletedAt: user.profileCompletedAt,
needsOnboard: needsOnboardGate({
isAdmin,
mustChangePassword: user.mustChangePassword,
profileCompletedAt: user.profileCompletedAt,
employeeId: user.employeeId,
idPhotoFileId,
idFrontFileId,
idBackFileId,
employee: user.employee as { idNo?: string | null; idType?: string | null } | null,
}),
employee: user.employee,
permissions: permissions.map((p) => p.code),
},
};
}
async startQrLogin() {
const ticket = randomUUID().replace(/-/g, '').slice(0, 16);
const expiresAt = new Date(Date.now() + 2 * 60 * 1000);
await this.prisma.authQrTicket.create({
data: { ticket, expiresAt },
});
return {
ticket,
expiresAt,
payload: `fengyingoa://qr?ticket=${ticket}`,
};
}
async qrStatus(ticket: string) {
const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: ticket.trim() } });
if (!row) throw new UnauthorizedException('二维码无效');
if (row.consumedAt || row.expiresAt < new Date()) {
if (row.status === 'pending') {
await this.prisma.authQrTicket.update({ where: { id: row.id }, data: { status: 'expired' } });
}
return { status: row.consumedAt ? 'consumed' : 'expired' };
}
if (row.status !== 'confirmed' || !row.userId) {
return { status: row.status };
}
// 扫码只证明是本人的手机;还需把手机屏幕上的一次性六码填到电脑,
// 才签发桌面端令牌,避免旁人代扫后直接登录电脑。
return { status: 'code_required', expiresAt: row.expiresAt };
}
async completeQrLogin(ticket: string, code: string) {
const rawTicket = ticket.trim();
const rawCode = code.trim();
if (!/^\d{6}$/.test(rawCode)) throw new BadRequestException('请输入手机显示的六位登录码');
const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: rawTicket } });
if (!row || row.expiresAt < new Date()) {
throw new BadRequestException('二维码已过期,请刷新后重新扫码');
}
if (row.status !== 'confirmed' || !row.userId || !row.confirmCodeHash) {
throw new BadRequestException('请先在手机端扫码确认');
}
if (row.confirmCodeAttempts >= 5) {
await this.prisma.authQrTicket.update({
where: { id: row.id },
data: { status: 'expired' },
});
throw new BadRequestException('登录码错误次数过多,请刷新二维码');
}
if (sha256(`${rawTicket}:${rawCode}`) !== row.confirmCodeHash) {
await this.prisma.authQrTicket.update({
where: { id: row.id },
data: { confirmCodeAttempts: { increment: 1 } },
});
throw new BadRequestException('六位登录码不正确');
}
const user = await this.prisma.user.findUnique({
where: { id: row.userId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: { include: { department: true } },
},
});
if (!user || user.status !== 'ACTIVE') throw new UnauthorizedException('账号不可用');
await this.prisma.authQrTicket.update({
where: { id: row.id },
data: { status: 'consumed', consumedAt: new Date() },
});
const tokens = await this.issueTokens(user, 'desktop');
return { status: 'ok', ...tokens };
}
async confirmQrLogin(userId: string, ticket: string) {
const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: ticket.trim() } });
if (!row || row.expiresAt < new Date()) throw new BadRequestException('二维码已过期,请在电脑上刷新');
if (row.status !== 'pending') throw new BadRequestException('这个码已经用过了');
const code = String(randomInt(0, 1_000_000)).padStart(6, '0');
await this.prisma.authQrTicket.update({
where: { id: row.id },
data: {
status: 'confirmed',
userId,
confirmCodeHash: sha256(`${row.ticket}:${code}`),
confirmCodeIssuedAt: new Date(),
confirmCodeAttempts: 0,
},
});
return { ok: true, code, expiresAt: row.expiresAt };
}
}
type ClientKind = 'web' | 'mobile' | 'desktop';
function normalizeClientKind(raw?: string | null): ClientKind {
const v = (raw || 'web').toLowerCase();
if (v === 'mobile' || v === 'android' || v === 'ios' || v === 'app') return 'mobile';
if (v === 'desktop' || v === 'windows' || v === 'pc') return 'desktop';
return 'web';
}
+71
View File
@@ -0,0 +1,71 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '../common/decorators/current-user.decorator';
import { mergeDataScope } from '../common/data-scope';
import { elevateToAdmin } from '../common/role-groups';
export interface JwtPayload {
sub: string;
username: string;
typ: 'access';
sid?: string;
ck?: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get<string>('JWT_ACCESS_SECRET')!,
});
}
async validate(payload: JwtPayload): Promise<AuthUser> {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: true,
},
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('登录已失效');
}
if (payload.sid) {
const session = await this.prisma.refreshToken.findFirst({
where: { id: payload.sid, userId: user.id, revokedAt: null },
});
if (!session || session.expiresAt < new Date()) {
throw new UnauthorizedException('登录已失效');
}
}
const roles = elevateToAdmin(user.roles.map((r: { role: { code: string } }) => r.role.code));
const permissions = [
...new Set(
user.roles.flatMap((r: { role: { permissions: { permission: { code: string } }[] } }) =>
r.role.permissions.map((p) => p.permission.code),
),
),
];
const dataScope = roles.includes('admin')
? 'ALL'
: mergeDataScope(user.roles.map((r: { role: { dataScope: string } }) => r.role.dataScope));
return {
id: user.id,
username: user.username,
displayName: user.displayName,
roles,
permissions,
employeeId: user.employeeId,
departmentId: user.employee?.departmentId ?? null,
dataScope,
};
}
}
+161
View File
@@ -0,0 +1,161 @@
import {
ASSIGN_ROLE,
canAccessBidModule,
canAssignFetcher,
canCreateBid,
canCreateSpecialBid,
canFetchBid,
canFetchThisBid,
canManageDraft,
canManageThisDraft,
canSeeAllBids,
canSeeTerminated,
canTerminateBid,
canTerminateFrom,
currentHandlerNames,
isCommercialStage,
isSpecialBid,
isSpecialCoordinatorUser,
stageForStatus,
previousBidStatus,
inferLegacyRestoreStatus,
} from './bid-workflow';
describe('bid-workflow', () => {
it('maps status to review stage', () => {
expect(stageForStatus('TECH_INITIAL')).toBe('TECH_INITIAL');
expect(stageForStatus('TECH_FINAL')).toBe('TECH_FINAL');
expect(stageForStatus('REVIEW1')).toBe('REVIEW1');
expect(stageForStatus('REVIEW2')).toBe('REVIEW2');
expect(stageForStatus('FETCH')).toBeNull();
expect(stageForStatus('DRAFTING')).toBeNull();
});
it('treats commercial reject as bounce, not terminate', () => {
expect(isCommercialStage('REVIEW1')).toBe(true);
expect(isCommercialStage('REVIEW2')).toBe(true);
expect(isCommercialStage('TECH_INITIAL')).toBe(false);
});
it('lets every director see all bids', () => {
expect(canSeeAllBids(['rd_director'])).toBe(true);
expect(canSeeAllBids(['3d_director'])).toBe(true);
expect(canSeeAllBids(['video_director'])).toBe(true);
expect(canSeeAllBids(['material_director'])).toBe(true);
expect(canSeeAllBids(['biz_director'])).toBe(true);
expect(canSeeAllBids(['tech_director'])).toBe(true);
expect(canSeeAllBids(['owner'])).toBe(true);
expect(canSeeAllBids(['admin'])).toBe(true);
expect(canSeeAllBids(['biz_staff'])).toBe(true);
expect(canSeeAllBids(['employee'])).toBe(false);
expect(canSeeAllBids(['pm'])).toBe(false);
});
it('hides terminated bids from ordinary employees', () => {
expect(canSeeTerminated(['employee'], ['office'])).toBe(false);
expect(canSeeTerminated(['hr'], ['office', 'hr'])).toBe(false);
expect(canSeeTerminated(['finance'], ['bid:result'])).toBe(false);
expect(canSeeTerminated(['biz_staff'], ['bid:screening'])).toBe(false);
expect(canSeeTerminated(['biz_director'], ['bid:terminated'])).toBe(true);
expect(canSeeTerminated(['tech_director'], ['bid'])).toBe(true);
expect(canSeeTerminated(['owner'], [])).toBe(true);
expect(canSeeTerminated(['admin'], [])).toBe(true);
});
it('restricts create / fetch / terminate by role', () => {
expect(canCreateBid(['owner'])).toBe(true);
expect(canCreateBid(['tech_director'])).toBe(false);
expect(canCreateBid(['employee'], ['bid:screening'])).toBe(true);
expect(canFetchBid(['biz_staff'])).toBe(true);
expect(canFetchBid(['tech_director'])).toBe(false);
expect(canTerminateBid(['biz_director'])).toBe(true);
expect(canTerminateBid(['biz_staff'])).toBe(false);
expect(canManageDraft(['biz_director'])).toBe(true);
expect(canManageDraft(['biz_staff'])).toBe(false);
});
it('keeps special screening on named coordinators', () => {
expect(isSpecialBid('SPECIAL')).toBe(true);
expect(isSpecialBid('FLOW')).toBe(false);
expect(canCreateSpecialBid(['owner'])).toBe(true);
expect(canCreateSpecialBid(['biz_director'])).toBe(false);
expect(isSpecialCoordinatorUser({ username: 'liuyunze', displayName: '刘运泽' })).toBe(true);
expect(isSpecialCoordinatorUser({ username: 'chencheng', displayName: '陈铖' })).toBe(true);
expect(isSpecialCoordinatorUser({ username: 'other', displayName: '其他人' })).toBe(false);
const coordinators = [{ userId: 'liu', role: ASSIGN_ROLE.COORDINATOR }];
const fetchers = [{ userId: 'staff', role: ASSIGN_ROLE.FETCHER }];
expect(canAssignFetcher(['3d_director'], 'SPECIAL', coordinators, 'liu', 'FETCH')).toBe(true);
expect(canAssignFetcher(['3d_director'], 'SPECIAL', coordinators, 'other', 'FETCH')).toBe(false);
expect(canAssignFetcher(['biz_director'], 'FLOW', [], 'x', 'FETCH')).toBe(true);
expect(canAssignFetcher(['biz_staff'], 'FLOW', [], 'x', 'FETCH')).toBe(false);
expect(canFetchThisBid(['employee'], 'SPECIAL', fetchers, 'staff')).toBe(true);
expect(canFetchThisBid(['biz_staff'], 'SPECIAL', fetchers, 'other')).toBe(false);
expect(canFetchThisBid(['biz_staff'], 'FLOW', [], 'other')).toBe(false);
expect(canFetchThisBid(['employee'], 'FLOW', fetchers, 'staff')).toBe(true);
expect(canManageThisDraft(['3d_director'], 'SPECIAL', coordinators, 'liu')).toBe(true);
expect(canManageThisDraft(['biz_director'], 'SPECIAL', coordinators, 'biz')).toBe(false);
expect(canManageThisDraft(['biz_director'], 'FLOW', [], 'biz')).toBe(true);
});
it('allows terminate until execution, not after open', () => {
expect(canTerminateFrom('TECH_INITIAL')).toBe(true);
expect(canTerminateFrom('DRAFTING')).toBe(true);
expect(canTerminateFrom('LIST')).toBe(true);
expect(canTerminateFrom('PENDING')).toBe(false);
expect(canTerminateFrom('WON')).toBe(false);
});
it('shows the person who should act on the current step', () => {
expect(
currentHandlerNames({
status: 'TECH_INITIAL',
reviewCycle: 1,
createdBy: { displayName: '潘兴辉' },
reviewers: [
{ stage: 'TECH_INITIAL', result: 'PENDING', cycle: 1, user: { displayName: '戴永康' } },
],
}),
).toBe('戴永康');
expect(
currentHandlerNames({
status: 'FETCH',
assignees: [{ role: 'FETCHER', user: { displayName: '何烨' } }],
createdBy: { displayName: '潘兴辉' },
}),
).toBe('何烨');
expect(
currentHandlerNames({
status: 'DRAFTING',
assignees: [{ role: 'DRAFTER', user: { displayName: '张婧妍' } }],
}),
).toBe('张婧妍');
expect(currentHandlerNames({ status: 'PRINT', createdBy: { displayName: '潘兴辉' } })).toBe('商务部');
expect(currentHandlerNames({ status: 'FETCH', createdBy: { displayName: '潘兴辉' } })).toBe('待派取标人');
});
it('rolls back one process step, except special fetch', () => {
expect(previousBidStatus('FETCH', 'FLOW')).toBe('TECH_INITIAL');
expect(previousBidStatus('FETCH', 'SPECIAL')).toBeNull();
expect(previousBidStatus('TECH_FINAL')).toBe('FETCH');
expect(previousBidStatus('DRAFTING')).toBe('TECH_FINAL');
expect(previousBidStatus('REVIEW1')).toBe('DRAFTING');
expect(previousBidStatus('REVIEW2')).toBe('REVIEW1');
expect(previousBidStatus('PRINT')).toBe('REVIEW2');
expect(previousBidStatus('LIST')).toBe('PRINT');
expect(previousBidStatus('PENDING')).toBe('LIST');
expect(previousBidStatus('TECH_INITIAL')).toBeNull();
expect(previousBidStatus('WON')).toBeNull();
});
it('infers the restore step for legacy terminated records without an action log', () => {
expect(inferLegacyRestoreStatus([], [])).toBe('DRAFTING');
expect(
inferLegacyRestoreStatus(
[{ stage: 'TECH_INITIAL', result: 'APPROVED' }],
[{ role: 'FETCHER' }, { role: 'DRAFTER' }],
),
).toBe('DRAFTING');
expect(inferLegacyRestoreStatus([], [{ role: 'BIDDER' }])).toBe('PENDING');
expect(inferLegacyRestoreStatus([{ stage: 'REVIEW2', result: 'PENDING' }], [])).toBe('REVIEW2');
});
});
+336
View File
@@ -0,0 +1,336 @@
import { DIRECTOR_ROLES, hasAnyRole, isTechLead } from '../common/role-groups';
export const BID_STATUS = {
TECH_INITIAL: 'TECH_INITIAL',
FETCH: 'FETCH',
TECH_FINAL: 'TECH_FINAL',
DRAFTING: 'DRAFTING',
REVIEW1: 'REVIEW1',
REVIEW2: 'REVIEW2',
PRINT: 'PRINT',
LIST: 'LIST',
PENDING: 'PENDING',
WON: 'WON',
LOST: 'LOST',
FAILED: 'FAILED',
TERMINATED: 'TERMINATED',
} as const;
export type BidStatus = (typeof BID_STATUS)[keyof typeof BID_STATUS];
export const STATUS_BUCKET: Record<string, string[]> = {
screening: [BID_STATUS.TECH_INITIAL, BID_STATUS.FETCH, BID_STATUS.TECH_FINAL],
drafting: [BID_STATUS.DRAFTING, BID_STATUS.REVIEW1, BID_STATUS.REVIEW2, BID_STATUS.PRINT],
execution: [BID_STATUS.LIST, BID_STATUS.PENDING],
result: [BID_STATUS.WON, BID_STATUS.LOST, BID_STATUS.FAILED],
terminated: [BID_STATUS.TERMINATED],
};
export const REVIEW_STAGE = {
TECH_INITIAL: 'TECH_INITIAL',
TECH_FINAL: 'TECH_FINAL',
REVIEW1: 'REVIEW1',
REVIEW2: 'REVIEW2',
} as const;
export type ReviewStage = (typeof REVIEW_STAGE)[keyof typeof REVIEW_STAGE];
export const BID_SOURCE = {
FLOW: 'FLOW',
SPECIAL: 'SPECIAL',
} as const;
export const ASSIGN_ROLE = {
DRAFTER: 'DRAFTER',
REVIEW1: 'REVIEW1',
BIDDER: 'BIDDER',
FETCHER: 'FETCHER',
COORDINATOR: 'COORDINATOR',
} as const;
/** 特殊筛选只发给这两人,由他们派取标人 / 制标人。 */
export const SPECIAL_COORDINATORS = [
{ username: 'liuyunze', name: '刘运泽' },
{ username: 'chencheng', name: '陈铖' },
] as const;
export const BIZ_TYPE = {
TECH_INITIAL: 'BID_TECH_INITIAL',
TECH_FINAL: 'BID_TECH_FINAL',
FETCH: 'BID_FETCH',
ASSIGN_FETCH: 'BID_ASSIGN_FETCH',
ASSIGN_DRAFT: 'BID_ASSIGN_DRAFT',
DRAFT: 'BID_DRAFT',
REVIEW1: 'BID_REVIEW1',
REVIEW2: 'BID_REVIEW2',
PRINT: 'BID_PRINT',
COMPLETE: 'BID_COMPLETE',
OPEN: 'BID_OPEN',
CONVERT_CONTRACT: 'CONTRACT_DRAFT',
} as const;
export const OPEN_RESULT = {
WON: 'WON',
LOST: 'LOST',
FAILED: 'FAILED',
} as const;
export const COST_DISPOSITION = {
DEPT: 'DEPT',
MARKETING: 'MARKETING',
} as const;
export function stageForStatus(status: string): ReviewStage | null {
if (status === BID_STATUS.TECH_INITIAL) return REVIEW_STAGE.TECH_INITIAL;
if (status === BID_STATUS.TECH_FINAL) return REVIEW_STAGE.TECH_FINAL;
if (status === BID_STATUS.REVIEW1) return REVIEW_STAGE.REVIEW1;
if (status === BID_STATUS.REVIEW2) return REVIEW_STAGE.REVIEW2;
return null;
}
/**
* Old OA imports did not always contain the TERMINATED action log. Infer the
* furthest assigned workflow step so those records can still be restored.
*/
export function inferLegacyRestoreStatus(
reviewers: { stage: string; result?: string | null }[],
assignees: { role: string }[],
): BidStatus {
const candidates: BidStatus[] = [];
for (const reviewer of reviewers) {
if (reviewer.result && reviewer.result !== 'PENDING') continue;
if (reviewer.stage === REVIEW_STAGE.TECH_INITIAL) candidates.push(BID_STATUS.TECH_INITIAL);
if (reviewer.stage === REVIEW_STAGE.TECH_FINAL) candidates.push(BID_STATUS.TECH_FINAL);
if (reviewer.stage === REVIEW_STAGE.REVIEW1) candidates.push(BID_STATUS.REVIEW1);
if (reviewer.stage === REVIEW_STAGE.REVIEW2) candidates.push(BID_STATUS.REVIEW2);
}
for (const assignee of assignees) {
if (assignee.role === ASSIGN_ROLE.FETCHER) candidates.push(BID_STATUS.FETCH);
if (assignee.role === ASSIGN_ROLE.DRAFTER) candidates.push(BID_STATUS.DRAFTING);
if (assignee.role === ASSIGN_ROLE.REVIEW1) candidates.push(BID_STATUS.REVIEW1);
if (assignee.role === ASSIGN_ROLE.BIDDER) candidates.push(BID_STATUS.PENDING);
}
const order: BidStatus[] = [
BID_STATUS.TECH_INITIAL,
BID_STATUS.FETCH,
BID_STATUS.TECH_FINAL,
BID_STATUS.DRAFTING,
BID_STATUS.REVIEW1,
BID_STATUS.REVIEW2,
BID_STATUS.PRINT,
BID_STATUS.LIST,
BID_STATUS.PENDING,
];
return candidates.sort((a, b) => order.indexOf(b) - order.indexOf(a))[0] ?? BID_STATUS.DRAFTING;
}
export function isCommercialStage(stage: string) {
return stage === REVIEW_STAGE.REVIEW1 || stage === REVIEW_STAGE.REVIEW2;
}
export function approvalBizType(stage: string) {
if (stage === REVIEW_STAGE.TECH_FINAL) return BIZ_TYPE.TECH_FINAL;
if (stage === REVIEW_STAGE.REVIEW1) return BIZ_TYPE.REVIEW1;
if (stage === REVIEW_STAGE.REVIEW2) return BIZ_TYPE.REVIEW2;
return BIZ_TYPE.TECH_INITIAL;
}
export function canAccessBidModule(roles: string[], permissions: string[]) {
if (roles.includes('admin')) return true;
return permissions.some((p) => p === 'bid' || p.startsWith('bid:'));
}
/** 管理员、负责人、商务部(总监/专员)、全体技术总监可看全部投标。 */
export function canSeeAllBids(roles: string[]) {
return hasAnyRole(roles, ['admin', 'owner', 'biz_staff', ...DIRECTOR_ROLES]);
}
export function canSeeTerminated(roles: string[], permissions: string[]) {
if (roles.includes('admin') || roles.includes('owner') || roles.includes('biz_director')) {
return true;
}
if (isTechLead(roles)) return true;
return permissions.includes('bid:terminated');
}
export function canCreateBid(roles: string[], permissions: string[] = []) {
if (['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r))) return true;
return permissions.includes('bid:screening');
}
export function canCreateSpecialBid(roles: string[]) {
return ['admin', 'owner'].some((r) => roles.includes(r));
}
export function isSpecialBid(sourceType?: string | null) {
return sourceType === BID_SOURCE.SPECIAL;
}
export function isSpecialCoordinatorUser(user: { username?: string | null; displayName?: string | null }) {
const username = (user.username || '').trim().toLowerCase();
const name = (user.displayName || '').trim();
return SPECIAL_COORDINATORS.some(
(c) => c.username === username || c.name === name,
);
}
export function hasAssigneeRole(
assignees: { userId: string; role: string }[],
userId: string,
role: string,
) {
return assignees.some((a) => a.userId === userId && a.role === role);
}
export function canFetchBid(roles: string[]) {
return ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r));
}
export function canFetchThisBid(
roles: string[],
sourceType: string | null | undefined,
assignees: { userId: string; role: string }[],
userId: string,
) {
// 普通/特殊筛选都必须由被派的取标人办理。过去普通筛选把整个商务部
// 当成取标人,既会群发短信,也会让任意人越权上传或完成取标。
return hasAssigneeRole(assignees, userId, ASSIGN_ROLE.FETCHER) || roles.includes('admin');
}
export function canManageDraft(roles: string[]) {
return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r));
}
export function canAssignFetcher(
roles: string[],
sourceType: string | null | undefined,
assignees: { userId: string; role: string }[],
userId: string,
status: string,
) {
if (status !== BID_STATUS.FETCH) return false;
if (isSpecialBid(sourceType)) {
return roles.includes('admin') || hasAssigneeRole(assignees, userId, ASSIGN_ROLE.COORDINATOR);
}
return ['admin', 'owner', 'biz_director'].some((role) => roles.includes(role));
}
export function canManageThisDraft(
roles: string[],
sourceType: string | null | undefined,
assignees: { userId: string; role: string }[],
userId: string,
) {
if (isSpecialBid(sourceType)) {
return roles.includes('admin') || hasAssigneeRole(assignees, userId, ASSIGN_ROLE.COORDINATOR);
}
return canManageDraft(roles);
}
export function canBizOperate(roles: string[]) {
return ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r));
}
export function canTerminateBid(roles: string[]) {
return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r));
}
const TERMINABLE_STATUSES: string[] = [
BID_STATUS.TECH_INITIAL,
BID_STATUS.FETCH,
BID_STATUS.TECH_FINAL,
BID_STATUS.DRAFTING,
BID_STATUS.REVIEW1,
BID_STATUS.REVIEW2,
BID_STATUS.PRINT,
BID_STATUS.LIST,
];
export function canTerminateFrom(status: string) {
return TERMINABLE_STATUSES.includes(status);
}
const PREVIOUS_STATUS: Partial<Record<BidStatus, BidStatus>> = {
[BID_STATUS.FETCH]: BID_STATUS.TECH_INITIAL,
[BID_STATUS.TECH_FINAL]: BID_STATUS.FETCH,
[BID_STATUS.DRAFTING]: BID_STATUS.TECH_FINAL,
[BID_STATUS.REVIEW1]: BID_STATUS.DRAFTING,
[BID_STATUS.REVIEW2]: BID_STATUS.REVIEW1,
[BID_STATUS.PRINT]: BID_STATUS.REVIEW2,
[BID_STATUS.LIST]: BID_STATUS.PRINT,
[BID_STATUS.PENDING]: BID_STATUS.LIST,
};
/** 当前步骤办错了,退回上一步重做。特殊筛选没有技术初审,取标不能再往回退。 */
export function previousBidStatus(status: string, sourceType?: string | null): BidStatus | null {
if (status === BID_STATUS.FETCH && isSpecialBid(sourceType)) return null;
return PREVIOUS_STATUS[status as BidStatus] || null;
}
export function canRollbackBid(roles: string[]) {
return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r));
}
type Named = { displayName?: string | null } | null | undefined;
function uniqNames(names: (string | null | undefined)[]) {
return [...new Set(names.map((n) => (n || '').trim()).filter(Boolean))];
}
/** 列表「经办」:当前这一步该谁办,就显示谁。 */
export function currentHandlerNames(bid: {
status: string;
sourceType?: string | null;
reviewCycle?: number;
createdBy?: Named;
assignees?: { role: string; user?: Named }[];
reviewers?: { stage: string; result: string; cycle: number; user?: Named }[];
}) {
const cycle = bid.reviewCycle ?? 1;
const byRole = (role: string) =>
uniqNames((bid.assignees || []).filter((a) => a.role === role).map((a) => a.user?.displayName));
const pending = (stage: string) => {
const rows = bid.reviewers || [];
const atCycle = rows.filter((r) => r.stage === stage && r.result === 'PENDING' && r.cycle === cycle);
const anyPending = rows.filter((r) => r.stage === stage && r.result === 'PENDING');
const pick = atCycle.length ? atCycle : anyPending;
return uniqNames(pick.map((r) => r.user?.displayName));
};
const show = (names: string[], fallback = '') => names.join('、') || fallback;
switch (bid.status) {
case BID_STATUS.TECH_INITIAL:
return show(pending(REVIEW_STAGE.TECH_INITIAL), bid.createdBy?.displayName || '—');
case BID_STATUS.FETCH: {
const fetchers = byRole(ASSIGN_ROLE.FETCHER);
if (fetchers.length) return show(fetchers);
if (isSpecialBid(bid.sourceType)) return show(byRole(ASSIGN_ROLE.COORDINATOR), '待派取标人');
return '待派取标人';
}
case BID_STATUS.TECH_FINAL:
return show(pending(REVIEW_STAGE.TECH_FINAL), '待终审');
case BID_STATUS.DRAFTING: {
const drafters = byRole(ASSIGN_ROLE.DRAFTER);
if (drafters.length) return show(drafters);
if (isSpecialBid(bid.sourceType)) return show(byRole(ASSIGN_ROLE.COORDINATOR), '待派制标人');
return '待派制标人';
}
case BID_STATUS.REVIEW1:
return show([...pending(REVIEW_STAGE.REVIEW1), ...byRole(ASSIGN_ROLE.REVIEW1)], '商务一审');
case BID_STATUS.REVIEW2:
return show(pending(REVIEW_STAGE.REVIEW2), '商务二审');
case BID_STATUS.PRINT:
return '商务部';
case BID_STATUS.LIST:
return show(byRole(ASSIGN_ROLE.BIDDER), '待派投标人');
case BID_STATUS.PENDING:
case BID_STATUS.WON:
case BID_STATUS.LOST:
case BID_STATUS.FAILED:
return show(byRole(ASSIGN_ROLE.BIDDER), bid.createdBy?.displayName || '—');
case BID_STATUS.TERMINATED:
return bid.createdBy?.displayName || '—';
default:
return bid.createdBy?.displayName || '—';
}
}
+389
View File
@@ -0,0 +1,389 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
IsDefined,
IsIn,
MinLength,
IsOptional,
IsString,
} from 'class-validator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthUser } from '../common/decorators/current-user.decorator';
import { PageQueryDto } from '../common/dto/page-query.dto';
import { BiddingService } from './bidding.service';
class CreateBidDto {
@IsString()
name!: string;
@IsString()
externalNo!: string;
@IsString()
projectType!: string;
@IsString()
source!: string;
@IsString()
qualificationNeed!: string;
@IsString()
legalEntityId!: string;
@IsOptional()
@IsString()
partyId?: string;
@IsOptional()
@IsString()
tenderMethod?: string;
@IsOptional()
applyStartAt?: string;
@IsOptional()
applyEndAt?: string;
@IsOptional()
@IsString()
applyMethod?: string;
@IsOptional()
openAt?: string;
@IsOptional()
@IsString()
openMethod?: string;
@IsOptional()
@IsString()
openPlace?: string;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
@IsString()
summary?: string;
@IsOptional()
@IsString()
nasPath?: string;
@IsOptional()
@IsString()
bondMethod?: string;
@IsOptional()
@IsString()
quoteAmount?: string;
@IsOptional()
@IsString()
priceCap?: string;
@IsArray()
@ArrayNotEmpty({ message: '请选择项目初审人或终审总监' })
@IsString({ each: true })
reviewerIds!: string[];
@IsOptional()
@IsIn(['FLOW', 'SPECIAL'])
sourceType?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
coordinatorIds?: string[];
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
needSurvey?: boolean;
}
class ReviewDto {
@IsIn(['APPROVED', 'REJECTED'])
result!: 'APPROVED' | 'REJECTED';
@IsOptional()
@IsString()
comment?: string;
}
class FetchDto {
@IsOptional()
@IsString()
nasPath?: string;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
openAt?: string;
@IsOptional()
@IsString()
openMethod?: string;
@IsOptional()
@IsString()
openPlace?: string;
@IsOptional()
@IsString()
applyMethod?: string;
}
class TerminateDto {
@IsString()
@MinLength(1, { message: '终止请填写原因' })
comment!: string;
}
class RollbackDto {
@IsString()
@MinLength(1, { message: '退回请填写原因' })
comment!: string;
}
class AssignFetcherDto {
@IsString()
fetcherId!: string;
}
class AssignDrafterDto {
@IsString()
drafterId!: string;
@IsOptional()
@IsString()
review1Id?: string;
}
class SubmitDraftDto {
@IsString()
@MinLength(1, { message: '请填写标书 NAS 路径' })
nasPath!: string;
@IsOptional()
@IsString()
note?: string;
}
class PrintDto {
@IsOptional()
amount?: number;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsString()
nasPath?: string;
}
class CompleteDto {
@IsOptional()
@IsString()
bondMethod?: string;
@IsOptional()
@IsString()
bondStatus?: string;
@IsOptional()
bondAmount?: number;
@IsOptional()
@IsString()
delivererName?: string;
@IsOptional()
@IsString()
priceCap?: string;
@IsOptional()
@IsString()
quoteAmount?: string;
@IsOptional()
@IsString()
securityLevel?: string;
@IsOptional()
@IsString()
contactName?: string;
@IsOptional()
@IsString()
contactPhone?: string;
@IsArray()
@ArrayNotEmpty({ message: '请派投标人' })
@IsString({ each: true })
bidderIds!: string[];
@IsOptional()
openAt?: string;
@IsOptional()
@IsString()
openPlace?: string;
@IsOptional()
@IsString()
openMethod?: string;
}
class OpenResultDto {
@IsIn(['WON', 'LOST', 'FAILED'])
result!: 'WON' | 'LOST' | 'FAILED';
@IsOptional()
@IsString()
comment?: string;
@IsOptional()
@IsIn(['DEPT', 'MARKETING'])
costDisposition?: 'DEPT' | 'MARKETING';
@IsOptional()
@IsString()
winnerName?: string;
@IsOptional()
winnerAmount?: number | string;
}
class CompetitorDto {
@IsString()
@MinLength(1, { message: '请填写中标单位' })
winnerName!: string;
@IsDefined({ message: '请填写中标金额' })
winnerAmount!: number | string;
@IsOptional()
@IsString()
winnerNote?: string;
}
class UpdateLegalEntityDto {
@IsString()
legalEntityId!: string;
}
@ApiTags('bidding')
@ApiBearerAuth()
@Controller('bid-cases')
export class BiddingController {
constructor(private readonly bidding: BiddingService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query() q: PageQueryDto,
@Query('bucket') bucket?: string,
@Query('sourceType') sourceType?: string,
@Query('projectType') projectType?: string,
@Query('status') status?: string,
@Query('legalEntityId') legalEntityId?: string,
) {
return this.bidding.list(user, q.page, q.pageSize, q.keyword, bucket, sourceType, projectType, status, legalEntityId);
}
@Get('special-coordinators')
specialCoordinators(@CurrentUser() user: AuthUser) {
return this.bidding.specialCoordinators(user);
}
@Get(':id')
get(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.bidding.get(id, user);
}
@Post()
create(@Body() dto: CreateBidDto, @CurrentUser() user: AuthUser) {
return this.bidding.create(dto, user);
}
@Patch(':id/survey')
setSurvey(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { needSurvey?: boolean },
@CurrentUser() user: AuthUser,
) {
return this.bidding.setNeedSurvey(id, user, Boolean(body.needSurvey));
}
@Post(':id/reviews')
review(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.review(id, user, dto);
}
@Post(':id/fetch')
fetch(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: FetchDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.fetch(id, user, dto);
}
@Post(':id/terminate')
terminate(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TerminateDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.terminate(id, user, dto.comment);
}
@Post(':id/rollback')
rollback(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RollbackDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.rollback(id, user, dto.comment);
}
@Post(':id/restore')
restore(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.bidding.restoreTerminated(id, user);
}
@Post(':id/assign-fetcher')
assignFetcher(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignFetcherDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.assignFetcher(id, user, dto);
}
@Post(':id/assign-drafter')
assignDrafter(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignDrafterDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.assignDrafter(id, user, dto);
}
@Post(':id/submit-draft')
submitDraft(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SubmitDraftDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.submitDraft(id, user, dto);
}
@Post(':id/print')
print(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: PrintDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.print(id, user, dto);
}
@Post(':id/complete')
complete(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CompleteDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.completeExecution(id, user, dto);
}
@Post(':id/open-result')
openResult(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: OpenResultDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.openResult(id, user, dto);
}
@Patch(':id/competitor')
competitor(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CompetitorDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.recordCompetitor(id, user, dto);
}
@Patch(':id/legal-entity')
updateLegalEntity(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateLegalEntityDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.updateLegalEntity(id, user, dto.legalEntityId);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { TECH_LEAD_ROLES } from '../common/role-groups';
import { PrismaService } from '../prisma/prisma.service';
@ApiTags('business')
@ApiBearerAuth()
@Controller()
export class BusinessController {
constructor(private readonly prisma: PrismaService) {}
@Get('directors')
async directors() {
const users = await this.prisma.user.findMany({
where: {
tenantId: '1',
roles: { some: { role: { code: { in: ['admin', 'tech_director', ...TECH_LEAD_ROLES] } } } },
},
include: { employee: { include: { department: true } }, roles: { include: { role: true } } },
});
return users.map((u) => ({
id: u.id,
name: u.displayName,
department: u.employee?.department?.name,
roles: u.roles.map((r) => r.role.code),
}));
}
@Get('staff')
async staff() {
const users = await this.prisma.user.findMany({
where: { tenantId: '1', status: 'ACTIVE' },
include: { employee: { include: { department: true } } },
orderBy: { displayName: 'asc' },
});
const avatars = await this.prisma.fileAsset.findMany({
where: { tenantId: '1', bizType: 'USER_AVATAR', bizId: { in: users.map((u) => u.id) } },
select: { id: true, bizId: true },
orderBy: { createdAt: 'desc' },
});
const avatarByUser = new Map<string, string>();
for (const a of avatars) {
if (!avatarByUser.has(a.bizId)) avatarByUser.set(a.bizId, a.id);
}
return users.map((u) => {
const acc = u.employee?.bankAccount?.replace(/\s/g, '') || '';
return {
id: u.id,
name: u.displayName,
username: u.username,
department: u.employee?.department?.name,
title: u.employee?.title,
mobile: u.employee?.mobile || null,
email: u.employee?.email || null,
avatarFileId: avatarByUser.get(u.id) || null,
bankReady: Boolean(acc),
bankName: u.employee?.bankName || null,
bankAccountMasked: acc ? `${'*'.repeat(Math.max(4, acc.length - 4))}${acc.slice(-4)}` : null,
};
});
}
}
+105
View File
@@ -0,0 +1,105 @@
import { ForbiddenException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { AuthUser } from './decorators/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
export type DataScope = 'ALL' | 'DEPT' | 'SELF';
const RANK: Record<DataScope, number> = { SELF: 0, DEPT: 1, ALL: 2 };
export function mergeDataScope(scopes: string[]): DataScope {
let best: DataScope = 'SELF';
for (const s of scopes) {
const v = s as DataScope;
if (RANK[v] > RANK[best]) best = v;
}
return best;
}
export function effectiveScope(user: AuthUser): DataScope {
if (user.roles.includes('admin') || user.roles.includes('owner')) return 'ALL';
return user.dataScope || 'SELF';
}
export async function descendantDeptIds(prisma: PrismaService, rootId?: string | null) {
if (!rootId) return [] as string[];
const all = await prisma.department.findMany({
where: { tenantId: '1' },
select: { id: true, parentId: true },
});
const ids = new Set<string>([rootId]);
let grew = true;
while (grew) {
grew = false;
for (const d of all) {
if (d.parentId && ids.has(d.parentId) && !ids.has(d.id)) {
ids.add(d.id);
grew = true;
}
}
}
return [...ids];
}
function empInScope(user: AuthUser, deptIds: string[]): Prisma.EmployeeWhereInput {
return {
OR: [
{ departmentId: { in: deptIds } },
...(user.employeeId
? [{ managerId: user.employeeId }, { managerIds: { contains: user.employeeId } }]
: []),
],
};
}
/** 本人 / 本部门及下级 / 全公司 */
export async function ownerScope(
prisma: PrismaService,
user: AuthUser,
owner: 'createdById' | 'applicantId',
): Promise<Prisma.BidCaseWhereInput | Prisma.ExpenseClaimWhereInput | Record<string, never>> {
const scope = effectiveScope(user);
if (scope === 'ALL') return {};
if (scope === 'SELF' || !user.departmentId) return { [owner]: user.id };
const deptIds = await descendantDeptIds(prisma, user.departmentId);
const rel = owner === 'createdById' ? 'createdBy' : 'applicant';
return {
OR: [{ [owner]: user.id }, { [rel]: { employee: empInScope(user, deptIds) } }],
};
}
export function withAnd<T extends { AND?: unknown }>(where: T, extra: Record<string, unknown>): T {
if (!extra || !Object.keys(extra).length) return where;
const prev = where.AND;
const list = Array.isArray(prev) ? [...prev] : prev ? [prev] : [];
list.push(extra);
where.AND = list;
return where;
}
export async function canSeeOwner(prisma: PrismaService, user: AuthUser, ownerId?: string | null) {
if (!ownerId) return true;
const scope = effectiveScope(user);
if (scope === 'ALL') return true;
if (ownerId === user.id) return true;
if (scope !== 'DEPT' || !user.departmentId) return false;
const owner = await prisma.user.findUnique({
where: { id: ownerId },
include: { employee: true },
});
if (!owner?.employee) return false;
const deptIds = await descendantDeptIds(prisma, user.departmentId);
if (owner.employee.departmentId && deptIds.includes(owner.employee.departmentId)) return true;
const extra = String(owner.employee.managerIds || '')
.split(/[,,、\s]+/)
.filter(Boolean);
return Boolean(
user.employeeId &&
(owner.employee.managerId === user.employeeId || extra.includes(user.employeeId)),
);
}
export async function assertCanSeeOwner(prisma: PrismaService, user: AuthUser, ownerId?: string | null) {
if (await canSeeOwner(prisma, user, ownerId)) return;
throw new ForbiddenException('超出数据范围,无权查看');
}
@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...codes: string[]) =>
SetMetadata(PERMISSIONS_KEY, codes);
@@ -0,0 +1,18 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthUser {
id: string;
username: string;
displayName: string;
permissions: string[];
roles: string[];
employeeId?: string | null;
departmentId?: string | null;
dataScope: 'ALL' | 'DEPT' | 'SELF';
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
return ctx.switchToHttp().getRequest().user as AuthUser;
},
);
+15
View File
@@ -0,0 +1,15 @@
import { nextSerial } from './doc-no';
describe('nextSerial', () => {
it('starts at 00001 when empty', () => {
expect(nextSerial([], 'BX-2026-')).toBe('BX-2026-00001');
});
it('uses max+1 so a hole in the middle does not collide', () => {
expect(nextSerial(['BX-2026-00001', 'BX-2026-00005'], 'BX-2026-')).toBe('BX-2026-00006');
});
it('ignores old date-style numbers that share the year digits', () => {
expect(nextSerial(['BX-20260617-005', 'BX-2026-00002'], 'BX-2026-')).toBe('BX-2026-00003');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { Prisma } from '@prisma/client';
const DOC_NO_LOCK: Record<string, number> = { BX: 91001, JK: 91002, BZ: 91003 };
export function nextSerial(existing: string[], prefix: string, pad = 5) {
let max = 0;
for (const no of existing) {
if (!no.startsWith(prefix)) continue;
const tail = no.slice(prefix.length);
if (/^\d+$/.test(tail)) {
const n = Number(tail);
if (Number.isFinite(n)) max = Math.max(max, n);
}
}
return `${prefix}${String(max + 1).padStart(pad, '0')}`;
}
export function isUniqueConflict(e: unknown) {
return e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002';
}
export async function withUniqueRetry<T>(fn: () => Promise<T>, times = 8): Promise<T> {
let last: unknown;
for (let i = 0; i < times; i += 1) {
try {
return await fn();
} catch (e) {
last = e;
if (!isUniqueConflict(e)) throw e;
}
}
throw last instanceof Error ? last : new Error('单号冲突,请重试');
}
type LockTx = { $executeRaw: Prisma.TransactionClient['$executeRaw'] };
export async function lockDocNo(tx: LockTx, prefix: 'BX' | 'JK' | 'BZ') {
const key = DOC_NO_LOCK[prefix];
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${key}::bigint)`;
}
type ClaimDb = {
expenseClaim: {
findMany: (args: {
where: { tenantId: string; claimNo: { startsWith: string } };
select: { claimNo: true };
}) => Promise<{ claimNo: string }[]>;
};
};
export async function nextExpenseClaimNo(tx: LockTx & ClaimDb, year = new Date().getFullYear()) {
await lockDocNo(tx, 'BX');
const prefix = `BX-${year}-`;
const rows = await tx.expenseClaim.findMany({
where: { tenantId: '1', claimNo: { startsWith: prefix } },
select: { claimNo: true },
});
return nextSerial(
rows.map((r) => r.claimNo),
prefix,
);
}
type BondDb = {
bondRecord: {
findMany: (args: {
where: { tenantId: string; bondNo: { startsWith: string } };
select: { bondNo: true };
}) => Promise<{ bondNo: string }[]>;
};
};
export async function nextBondNo(tx: LockTx & BondDb, year = new Date().getFullYear()) {
await lockDocNo(tx, 'BZ');
const prefix = `BZ-${year}-`;
const rows = await tx.bondRecord.findMany({
where: { tenantId: '1', bondNo: { startsWith: prefix } },
select: { bondNo: true },
});
return nextSerial(
rows.map((r) => r.bondNo),
prefix,
);
}
+24
View File
@@ -0,0 +1,24 @@
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class PageQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 10;
@IsOptional()
keyword?: string;
}
export function pageMeta(page: number, pageSize: number, total: number) {
return { page, pageSize, total };
}
@@ -0,0 +1,84 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Prisma } from '@prisma/client';
import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware';
import { PrismaService } from '../../prisma/prisma.service';
import { LOG_ACTION, writeOpLog } from '../../system/op-log';
import type { AuthUser } from '../decorators/current-user.decorator';
function publicErrorMessage(exception: unknown) {
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === 'P2002') return '单号已被占用,请再试一次';
return '保存失败,请稍后重试';
}
if (exception instanceof Error) {
if (/Invalid `prisma\.|Unique constraint failed/i.test(exception.message)) {
return '单号已被占用,请再试一次';
}
return exception.message;
}
return '服务器内部错误';
}
@Catch()
@Injectable()
export class HttpErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpErrorFilter.name);
constructor(private readonly prisma: PrismaService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request & { user?: AuthUser }>();
const requestId = (req.headers[REQUEST_ID_HEADER] as string) || '';
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
if (exception instanceof HttpException) {
status = exception.getStatus();
const payload = exception.getResponse();
if (typeof payload === 'string') {
message = payload;
} else if (typeof payload === 'object' && payload && 'message' in payload) {
const raw = (payload as { message: string | string[] }).message;
message = Array.isArray(raw) ? raw.join('; ') : raw;
}
} else {
message = publicErrorMessage(exception);
}
this.logger.error(
`${req.method} ${req.url} ${status} ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
if (status === HttpStatus.FORBIDDEN) {
void writeOpLog(this.prisma, {
userId: req.user?.id,
username: req.user?.username,
action: LOG_ACTION.FORBIDDEN,
path: `${req.method} ${req.originalUrl || req.url}`,
detail: message,
success: false,
requestId,
ip: req.ip,
});
}
res.status(status).json({
code: status,
message,
data: null,
requestId,
});
}
}
+37
View File
@@ -0,0 +1,37 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/auth.decorators';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>('permissions', [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const user = context.switchToHttp().getRequest().user;
if (!user?.permissions) return false;
if (user.roles?.includes('admin')) return true;
return required.every((code) => user.permissions.includes(code));
}
}
@@ -0,0 +1,40 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
StreamableFile,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware';
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
requestId: string;
}
@Injectable()
export class ResponseInterceptor<T>
implements NestInterceptor<T, ApiResponse<T> | StreamableFile>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T> | StreamableFile> {
const req = context.switchToHttp().getRequest();
const requestId = (req.headers[REQUEST_ID_HEADER] as string) || '';
return next.handle().pipe(
map((data) => {
if (data instanceof StreamableFile) return data;
return {
code: 0,
message: 'ok',
data: (data ?? null) as T,
requestId,
};
}),
);
}
}
@@ -0,0 +1,15 @@
import { randomUUID } from 'crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
export const REQUEST_ID_HEADER = 'x-request-id';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const id = (req.headers[REQUEST_ID_HEADER] as string) || randomUUID();
req.headers[REQUEST_ID_HEADER] = id;
res.setHeader(REQUEST_ID_HEADER, id);
next();
}
}

Some files were not shown because too many files have changed in this diff Show More