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:
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: 个人办公申请必须走统一流程,禁止再做孤立页面和三五个文字链筛选
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 办公申请怎么长
|
||||
|
||||
个人办公是「人发起、人跟踪、落地到台账」。不要再按用户点到哪一页就只改那一页。
|
||||
|
||||
## 列表筛选
|
||||
|
||||
- 用 `ApplyBucketBar`(Segmented + `?bucket=`),有选中态。
|
||||
- 标准桶:全部 / 待审批 / 已通过 / 已驳回。领域附加桶(外带未还、占用中、待入库)可以加,不能替代标准桶。
|
||||
- 禁止再在页头放三个无选中态的 `Link`(「全部」「待审批」「外带未还」这种)。
|
||||
|
||||
## 菜单
|
||||
|
||||
- 「我的申请」是跟踪入口,人事/用章/办公申请是发起入口,待我审批是审批入口。不要做成三套互不相通的列表。
|
||||
- 申请人页只看自己的单;台账(用章登记、资产管理、采购申请)在业务模块。
|
||||
|
||||
## 审批通过必须写下游
|
||||
|
||||
| 申请 | 落到 |
|
||||
|---|---|
|
||||
| 资产购置 | 采购单 + 待入库台账 + 付款待办 |
|
||||
| 资产领用 / 用车 | 占用记录(挂台账 id),交回不改保管人 |
|
||||
| 出差 | 差旅报销草稿 |
|
||||
| 用章外带 | 归还待办 |
|
||||
|
||||
保管人是台账责任人,占用是谁正在用。占用时不要把保管人改成持有人。
|
||||
@@ -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。
|
||||
@@ -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
@@ -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
@@ -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 对不上 debug,HMS 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` | 同类 Notice,kind 不同 |
|
||||
|
||||
### 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 一起改。改完版本四个文件一起改。
|
||||
@@ -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
|
||||
@@ -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`。
|
||||
|
||||
Swagger:http://localhost:3000/api/v1/docs
|
||||
|
||||
## 模块
|
||||
|
||||
个人办公、往来单位、证照用章、投标管理、合同管理、项目管理、财务收支、组织人事、经营报表、系统设置。
|
||||
|
||||
投标筛选可以新建。其余页面已接通列表接口,数据会随使用增加。
|
||||
@@ -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 业务代码,包含通话状态同步和工作台业务卡片转发功能。
|
||||
@@ -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。
|
||||
@@ -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"}}]}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
|
||||
- Web:http://localhost:5173
|
||||
- API:http://localhost:3000/api/v1
|
||||
- Swagger:http://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。
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+7234
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
/** OA 雪花 ID 超过 Number.MAX_SAFE_INTEGER,JSON.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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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('*');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 || '—';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user