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,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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { assembleApprovalChain } from './org-chain';
|
||||
|
||||
const staff = { id: 'staff', displayName: '员工甲', username: 'a' };
|
||||
const pm = { id: 'pm', displayName: '项目经理', username: 'pm' };
|
||||
const dir = { id: 'dir', displayName: '研发总监', username: 'rd' };
|
||||
const pan = { id: 'pan', displayName: '潘兴辉', username: 'pan' };
|
||||
const biz = { id: 'biz', displayName: '王迪', username: 'wangdi' };
|
||||
|
||||
describe('assembleApprovalChain', () => {
|
||||
it('staff goes PM → dept director → 潘兴辉 → 商务总监', () => {
|
||||
expect(
|
||||
assembleApprovalChain({
|
||||
applicantId: staff.id,
|
||||
manager: pm,
|
||||
director: dir,
|
||||
pan,
|
||||
bizDirector: biz,
|
||||
}).map((s) => `${s.role}:${s.user.displayName}`),
|
||||
).toEqual(['项目经理:项目经理', '部门总监:研发总监', '潘兴辉:潘兴辉', '商务总监:王迪']);
|
||||
});
|
||||
|
||||
it('skips the applicant and duplicate people', () => {
|
||||
expect(
|
||||
assembleApprovalChain({
|
||||
applicantId: pm.id,
|
||||
manager: pm,
|
||||
director: dir,
|
||||
pan,
|
||||
bizDirector: biz,
|
||||
}).map((s) => s.role),
|
||||
).toEqual(['部门总监', '潘兴辉', '商务总监']);
|
||||
expect(
|
||||
assembleApprovalChain({
|
||||
applicantId: staff.id,
|
||||
manager: pm,
|
||||
director: biz,
|
||||
pan,
|
||||
bizDirector: biz,
|
||||
}).map((s) => s.role),
|
||||
).toEqual(['项目经理', '部门总监', '潘兴辉']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { DIRECTOR_ROLES, TECH_LEAD_ROLES } from './role-groups';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export const PERSON_SELECT = { id: true, displayName: true, username: true } as const;
|
||||
export type OrgPerson = { id: string; displayName: string; username: string };
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function parsePeopleIds(raw?: string | null) {
|
||||
if (!raw) return [] as string[];
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(/[,,、\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => UUID_RE.test(s)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
type OrgDb = PrismaService | Prisma.TransactionClient;
|
||||
|
||||
const PAN_NAME = '潘兴辉';
|
||||
|
||||
export function assembleApprovalChain(input: {
|
||||
applicantId: string;
|
||||
manager: OrgPerson | null;
|
||||
director: OrgPerson | null;
|
||||
pan: OrgPerson | null;
|
||||
bizDirector: OrgPerson | null;
|
||||
}): { role: string; user: OrgPerson }[] {
|
||||
const steps: { role: string; user: OrgPerson }[] = [];
|
||||
const push = (role: string, user: OrgPerson | null | undefined) => {
|
||||
if (!user) return;
|
||||
if (user.id === input.applicantId) return;
|
||||
if (steps.some((s) => s.user.id === user.id)) return;
|
||||
steps.push({ role, user });
|
||||
};
|
||||
push('项目经理', input.manager);
|
||||
push('部门总监', input.director);
|
||||
push('潘兴辉', input.pan);
|
||||
push('商务总监', input.bizDirector);
|
||||
return steps;
|
||||
}
|
||||
|
||||
export async function ancestorDeptIds(db: OrgDb, startId?: string | null) {
|
||||
if (!startId) return [] as string[];
|
||||
const all = await db.department.findMany({
|
||||
where: { tenantId: '1' },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cur: string | null | undefined = startId;
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
ids.push(cur);
|
||||
cur = all.find((d) => d.id === cur)?.parentId;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function findDeptDirector(db: OrgDb, departmentId?: string | null): Promise<OrgPerson | null> {
|
||||
const chain = await ancestorDeptIds(db, departmentId);
|
||||
for (const deptId of chain) {
|
||||
const tech = await db.user.findFirst({
|
||||
where: {
|
||||
tenantId: '1',
|
||||
status: 'ACTIVE',
|
||||
employee: { departmentId: deptId },
|
||||
roles: { some: { role: { code: { in: [...TECH_LEAD_ROLES] } } } },
|
||||
},
|
||||
select: PERSON_SELECT,
|
||||
});
|
||||
if (tech) return tech;
|
||||
const any = await db.user.findFirst({
|
||||
where: {
|
||||
tenantId: '1',
|
||||
status: 'ACTIVE',
|
||||
employee: { departmentId: deptId },
|
||||
roles: { some: { role: { code: { in: [...DIRECTOR_ROLES] } } } },
|
||||
},
|
||||
select: PERSON_SELECT,
|
||||
});
|
||||
if (any) return any;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findNamedPerson(db: OrgDb, name: string): Promise<OrgPerson | null> {
|
||||
const users = await db.user.findMany({
|
||||
where: {
|
||||
tenantId: '1',
|
||||
status: 'ACTIVE',
|
||||
OR: [{ displayName: { contains: name } }, { employee: { name: { contains: name } } }],
|
||||
},
|
||||
select: { ...PERSON_SELECT, employee: { select: { name: true } } },
|
||||
});
|
||||
const exact = users.find((u) => u.displayName === name || u.employee?.name === name);
|
||||
const hit = exact || users[0];
|
||||
return hit ? { id: hit.id, displayName: hit.displayName, username: hit.username } : null;
|
||||
}
|
||||
|
||||
/** 投标/报销里的「商务总监」指商务部负责人,不含其他部门误挂 biz_director 的同事。 */
|
||||
export async function findBizDirectors(db: OrgDb): Promise<OrgPerson[]> {
|
||||
const inBizDept = await db.user.findMany({
|
||||
where: {
|
||||
tenantId: '1',
|
||||
status: 'ACTIVE',
|
||||
roles: { some: { role: { code: 'biz_director' } } },
|
||||
employee: {
|
||||
department: {
|
||||
OR: [{ name: { contains: '商务' } }, { code: { contains: 'BIZ' } }, { code: { startsWith: 'OA_4' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: PERSON_SELECT,
|
||||
orderBy: { displayName: 'asc' },
|
||||
});
|
||||
if (inBizDept.length) return inBizDept;
|
||||
return db.user.findMany({
|
||||
where: {
|
||||
tenantId: '1',
|
||||
status: 'ACTIVE',
|
||||
roles: { some: { role: { code: 'biz_director' } } },
|
||||
},
|
||||
select: PERSON_SELECT,
|
||||
orderBy: { displayName: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function findRolePerson(db: OrgDb, roleCode: string, exceptId?: string | null): Promise<OrgPerson | null> {
|
||||
if (roleCode === 'biz_director') {
|
||||
const users = await findBizDirectors(db);
|
||||
return users.find((u) => u.id !== exceptId) || users[0] || null;
|
||||
}
|
||||
const users = await db.user.findMany({
|
||||
where: {
|
||||
tenantId: '1',
|
||||
status: 'ACTIVE',
|
||||
roles: { some: { role: { code: roleCode } } },
|
||||
},
|
||||
select: PERSON_SELECT,
|
||||
});
|
||||
return users.find((u) => u.id !== exceptId) || users[0] || null;
|
||||
}
|
||||
|
||||
function managerUserOf(emp: {
|
||||
manager?: { users: Array<{ id: string; displayName: string; username: string }> } | null;
|
||||
} | null): OrgPerson | null {
|
||||
const u = emp?.manager?.users[0];
|
||||
return u ? { id: u.id, displayName: u.displayName, username: u.username } : null;
|
||||
}
|
||||
|
||||
export async function resolveExpenseApproverChain(db: OrgDb, applicantId: string) {
|
||||
const applicant = await db.user.findFirst({
|
||||
where: { id: applicantId },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
manager: { include: { users: { where: { status: 'ACTIVE' }, take: 1 } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const [director, pan, bizDirector] = await Promise.all([
|
||||
findDeptDirector(db, applicant?.employee?.departmentId),
|
||||
findNamedPerson(db, PAN_NAME),
|
||||
findRolePerson(db, 'biz_director'),
|
||||
]);
|
||||
return assembleApprovalChain({
|
||||
applicantId,
|
||||
manager: managerUserOf(applicant?.employee || null),
|
||||
director,
|
||||
pan,
|
||||
bizDirector,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveReportPeople(db: OrgDb, userId: string) {
|
||||
const me = await db.user.findFirst({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
manager: { include: { users: { where: { status: 'ACTIVE' }, take: 1 } } },
|
||||
reports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const director = await findDeptDirector(db, me?.employee?.departmentId);
|
||||
const extraIds = parsePeopleIds((me?.employee as { managerIds?: string | null } | undefined)?.managerIds).filter(
|
||||
(id) => id !== me?.employee?.managerId,
|
||||
);
|
||||
const extraManagers = extraIds.length
|
||||
? await db.user.findMany({
|
||||
where: { tenantId: '1', status: 'ACTIVE', employeeId: { in: extraIds } },
|
||||
select: PERSON_SELECT,
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
me,
|
||||
manager: managerUserOf(me?.employee || null),
|
||||
extraManagers,
|
||||
director,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export const TECH_LEAD_ROLES = [
|
||||
'tech_director',
|
||||
'rd_director',
|
||||
'3d_director',
|
||||
'video_director',
|
||||
'material_director',
|
||||
];
|
||||
|
||||
/** 商务总监 + 各技术条线总监 */
|
||||
export const DIRECTOR_ROLES = ['biz_director', ...TECH_LEAD_ROLES];
|
||||
|
||||
/** 手工发短信:管理员、人事、各总监。 */
|
||||
export const SMS_SEND_ROLES = ['admin', 'owner', 'hr', ...DIRECTOR_ROLES];
|
||||
|
||||
export function canSendSms(roles: string[], permissions: string[] = []) {
|
||||
if (hasAnyRole(roles, SMS_SEND_ROLES)) return true;
|
||||
return permissions.includes('office:sms') || permissions.includes('system:sms');
|
||||
}
|
||||
|
||||
export const TECH_STAFF_ROLES = ['employee', 'pm', 'rd_staff', '3d_staff', 'video_staff', 'material_staff', 'tech'];
|
||||
|
||||
export const APPROVER_ROLES = [
|
||||
'admin',
|
||||
'owner',
|
||||
'biz_director',
|
||||
'biz_staff',
|
||||
'hr',
|
||||
'finance',
|
||||
...TECH_LEAD_ROLES,
|
||||
];
|
||||
|
||||
export function isStaffRole(roles: string[]) {
|
||||
return roles.length > 0 && roles.every((r) => TECH_STAFF_ROLES.includes(r));
|
||||
}
|
||||
|
||||
export function canApproveOthers(roles: string[], permissions: string[]) {
|
||||
if (hasAnyRole(roles, APPROVER_ROLES)) return true;
|
||||
return permissions.includes('office:approvals');
|
||||
}
|
||||
|
||||
export function hasAnyRole(roles: string[], codes: readonly string[]) {
|
||||
return codes.some((c) => roles.includes(c));
|
||||
}
|
||||
|
||||
export function isTechLead(roles: string[]) {
|
||||
return hasAnyRole(roles, TECH_LEAD_ROLES);
|
||||
}
|
||||
|
||||
/** 研发总监与系统管理员同等。登录后补上 admin,后端原有管理员判断都会生效。 */
|
||||
export function elevateToAdmin(roles: string[]) {
|
||||
if (roles.includes('rd_director') && !roles.includes('admin')) return [...roles, 'admin'];
|
||||
return roles;
|
||||
}
|
||||
|
||||
/** 总监、项目经理、公司负责人可给下属分配工作。 */
|
||||
export const WORK_ASSIGN_ROLES = ['admin', 'owner', 'biz_director', 'pm', ...TECH_LEAD_ROLES];
|
||||
|
||||
export function canAssignWork(roles: string[]) {
|
||||
return hasAnyRole(roles, WORK_ASSIGN_ROLES);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
function key() {
|
||||
return process.env.TENCENT_MAP_KEY || '';
|
||||
}
|
||||
|
||||
const REFERER = 'https://oa.fysxkj.com/';
|
||||
|
||||
async function tencentGet(path: string, params: Record<string, string>) {
|
||||
const k = key();
|
||||
if (!k) return null;
|
||||
const q = new URLSearchParams({ key: k, output: 'json', ...params });
|
||||
const res = await fetch(`https://apis.map.qq.com${path}?${q.toString()}`, {
|
||||
headers: { Referer: REFERER },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function locateByIp(ip?: string) {
|
||||
const json = await tencentGet('/ws/location/v1/ip', ip ? { ip } : {});
|
||||
const result = json?.result as
|
||||
| { location?: { lat?: number; lng?: number }; ad_info?: { city?: string; province?: string; district?: string } }
|
||||
| undefined;
|
||||
if (json?.status !== 0 || !result?.location) return null;
|
||||
return {
|
||||
lat: Number(result.location.lat),
|
||||
lng: Number(result.location.lng),
|
||||
city: String(result.ad_info?.city || result.ad_info?.province || '').replace(/市$/, ''),
|
||||
district: String(result.ad_info?.district || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function tencentMapKey() {
|
||||
return key();
|
||||
}
|
||||
|
||||
export type PlaceHit = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
title: string;
|
||||
address: string;
|
||||
city?: string;
|
||||
distance?: number;
|
||||
};
|
||||
|
||||
function asPoi(p: {
|
||||
title?: string;
|
||||
address?: string;
|
||||
location?: { lat?: number; lng?: number };
|
||||
_distance?: number | string;
|
||||
ad_info?: { city?: string };
|
||||
}): PlaceHit | null {
|
||||
const lat = Number(p.location?.lat);
|
||||
const lng = Number(p.location?.lng);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
const dist = Number(p._distance);
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
title: String(p.title || p.address || '位置'),
|
||||
address: String(p.address || ''),
|
||||
city: String(p.ad_info?.city || '').replace(/市$/, ''),
|
||||
distance: Number.isFinite(dist) ? dist : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function reverseGeocode(lat: number, lng: number) {
|
||||
const json = await tencentGet('/ws/geocoder/v1/', {
|
||||
location: `${lat},${lng}`,
|
||||
get_poi: '1',
|
||||
poi_options: 'address_format=short;radius=1000;page_size=20;policy=2',
|
||||
});
|
||||
const result = json?.result as
|
||||
| {
|
||||
address?: string;
|
||||
formatted_addresses?: { recommend?: string; rough?: string };
|
||||
address_component?: { city?: string; province?: string; district?: string };
|
||||
pois?: Array<{
|
||||
title?: string;
|
||||
address?: string;
|
||||
location?: { lat?: number; lng?: number };
|
||||
_distance?: number | string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
if (json?.status !== 0 || !result) return null;
|
||||
const address = String(result.address || '');
|
||||
const title = String(result.formatted_addresses?.recommend || result.formatted_addresses?.rough || address);
|
||||
return {
|
||||
address,
|
||||
title: title || address,
|
||||
city: String(result.address_component?.city || result.address_component?.province || '').replace(/市$/, ''),
|
||||
district: String(result.address_component?.district || ''),
|
||||
lat,
|
||||
lng,
|
||||
pois: (result.pois || []).map(asPoi).filter((p): p is PlaceHit => !!p),
|
||||
};
|
||||
}
|
||||
|
||||
export async function suggestPlaces(keyword: string, lat?: number, lng?: number) {
|
||||
const q = keyword.trim();
|
||||
if (!q) return [] as PlaceHit[];
|
||||
const params: Record<string, string> = { keyword: q, page_size: '20' };
|
||||
if (Number.isFinite(Number(lat)) && Number.isFinite(Number(lng))) params.location = `${lat},${lng}`;
|
||||
const json = await tencentGet('/ws/place/v1/suggestion/', params);
|
||||
const rows = (json?.data as Array<Parameters<typeof asPoi>[0]>) || [];
|
||||
return rows.map(asPoi).filter((p): p is PlaceHit => !!p);
|
||||
}
|
||||
|
||||
const CITIES = [
|
||||
'合肥',
|
||||
'南京',
|
||||
'北京',
|
||||
'上海',
|
||||
'杭州',
|
||||
'苏州',
|
||||
'无锡',
|
||||
'宁波',
|
||||
'武汉',
|
||||
'成都',
|
||||
'西安',
|
||||
'广州',
|
||||
'深圳',
|
||||
'青岛',
|
||||
'济南',
|
||||
'郑州',
|
||||
'长沙',
|
||||
'南昌',
|
||||
'福州',
|
||||
'厦门',
|
||||
'天津',
|
||||
'重庆',
|
||||
'沈阳',
|
||||
'大连',
|
||||
'哈尔滨',
|
||||
'长春',
|
||||
'昆明',
|
||||
'贵阳',
|
||||
'南宁',
|
||||
'海口',
|
||||
'兰州',
|
||||
'银川',
|
||||
'西宁',
|
||||
'太原',
|
||||
'石家庄',
|
||||
'呼和浩特',
|
||||
'乌鲁木齐',
|
||||
];
|
||||
|
||||
function cityHint(text: string) {
|
||||
return CITIES.find((c) => text.includes(c)) || '';
|
||||
}
|
||||
|
||||
function poiScore(query: string, title: string, addr: string) {
|
||||
const hay = `${title}${addr}`;
|
||||
if (title === query) return 1000;
|
||||
if (title.includes(query) || query.includes(title)) return 800;
|
||||
let s = 0;
|
||||
for (const part of query.match(/[\u4e00-\u9fff]{2,}/g) || []) {
|
||||
if (hay.includes(part)) s += part.length * 10;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
type GeoHit = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
};
|
||||
|
||||
function asHit(lat: number, lng: number, title: string, address: string, city: string): GeoHit {
|
||||
return { lat, lng, title, address, city };
|
||||
}
|
||||
|
||||
export async function geocodeAddress(address: string) {
|
||||
const q = address.trim();
|
||||
if (!q) return null;
|
||||
const geo = await tencentGet('/ws/geocoder/v1/', { address: q });
|
||||
const geoResult = geo?.result as
|
||||
| { location?: { lat?: number; lng?: number }; title?: string; address?: string }
|
||||
| undefined;
|
||||
if (geo?.status === 0 && geoResult?.location) {
|
||||
const extra = await reverseGeocode(Number(geoResult.location.lat), Number(geoResult.location.lng));
|
||||
return asHit(
|
||||
Number(geoResult.location.lat),
|
||||
Number(geoResult.location.lng),
|
||||
String(geoResult.title || q),
|
||||
extra?.address || String(geoResult.address || q),
|
||||
extra?.city || '',
|
||||
);
|
||||
}
|
||||
|
||||
const city = cityHint(q);
|
||||
const [sug, search] = await Promise.all([
|
||||
tencentGet('/ws/place/v1/suggestion/', city ? { keyword: q, region: city } : { keyword: q }),
|
||||
tencentGet('/ws/place/v1/search/', {
|
||||
keyword: q,
|
||||
boundary: city ? `region(${city},0)` : 'region(全国,0)',
|
||||
page_size: '10',
|
||||
}),
|
||||
]);
|
||||
type Poi = { title?: string; address?: string; location?: { lat?: number; lng?: number }; ad_info?: { city?: string } };
|
||||
const pois = [...((sug?.data as Poi[]) || []), ...((search?.data as Poi[]) || [])].filter(
|
||||
(p) => p?.location && Number.isFinite(Number(p.location.lat)),
|
||||
);
|
||||
if (!pois.length) return null;
|
||||
const ranked = [...pois].sort(
|
||||
(a, b) =>
|
||||
poiScore(q, String(b.title || ''), String(b.address || '')) -
|
||||
poiScore(q, String(a.title || ''), String(a.address || '')),
|
||||
);
|
||||
const best = ranked[0];
|
||||
const extra = await reverseGeocode(Number(best.location!.lat), Number(best.location!.lng));
|
||||
return asHit(
|
||||
Number(best.location!.lat),
|
||||
Number(best.location!.lng),
|
||||
String(best.title || q),
|
||||
extra?.address || String(best.address || q),
|
||||
extra?.city || String(best.ad_info?.city || city).replace(/市$/, ''),
|
||||
);
|
||||
}
|
||||
|
||||
type WeatherNow = {
|
||||
city: string;
|
||||
weather: string;
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
wind?: string;
|
||||
advice: string;
|
||||
};
|
||||
|
||||
function adviceFrom(info: string, temp?: number) {
|
||||
const tips: string[] = [];
|
||||
if (/雨|雪|雷/.test(info)) tips.push('注意下雨,出门带伞');
|
||||
if (/晴/.test(info) || (typeof temp === 'number' && temp >= 30)) tips.push('注意紫外线,做好防晒');
|
||||
if (typeof temp === 'number' && temp >= 35) tips.push('注意防暑降温');
|
||||
if (typeof temp === 'number' && temp <= 5) tips.push('注意保暖');
|
||||
if (/雾|霾|沙/.test(info)) tips.push('注意出行能见度');
|
||||
if (/风/.test(info) && !/微风/.test(info)) tips.push('注意大风');
|
||||
if (!tips.length) tips.push('注意劳逸结合');
|
||||
return [...new Set(tips)].slice(0, 2).join(',');
|
||||
}
|
||||
|
||||
export async function weatherByLocation(lat: number, lng: number, cityHint?: string): Promise<WeatherNow | null> {
|
||||
const json = await tencentGet('/ws/weather/v1/', {
|
||||
location: `${lat},${lng}`,
|
||||
type: 'now',
|
||||
added_fields: 'alarm',
|
||||
});
|
||||
if (json?.status !== 0) return null;
|
||||
const result = json.result as { realtime?: Array<Record<string, unknown>> } | undefined;
|
||||
const row = result?.realtime?.[0];
|
||||
if (!row) return null;
|
||||
const infos = (row.infos || {}) as Record<string, unknown>;
|
||||
const info = String(infos.weather || '—');
|
||||
const temperature = Number(infos.temperature);
|
||||
const humidity = Number(infos.humidity);
|
||||
const windDir = String(infos.wind_direction || '');
|
||||
const windPower = String(infos.wind_power_v2 || infos.wind_power || '');
|
||||
const city = String(row.city || cityHint || '').replace(/市$/, '');
|
||||
return {
|
||||
city: city || '当地',
|
||||
weather: info,
|
||||
temperature: Number.isFinite(temperature) ? temperature : undefined,
|
||||
humidity: Number.isFinite(humidity) ? humidity : undefined,
|
||||
wind: [windDir, windPower].filter(Boolean).join(' ') || undefined,
|
||||
advice: adviceFrom(info, Number.isFinite(temperature) ? temperature : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const GREETINGS = [
|
||||
(n: string) => `尊敬的${n}您好,美好的一天开始了,祝您工作愉快。`,
|
||||
(n: string) => `尊敬的${n},新的一天已就绪,愿今天经手的每一件事都顺利落地。`,
|
||||
(n: string) => `${n},早上好。保持节奏,今天也会很出色。`,
|
||||
(n: string) => `尊敬的${n}您好,愿今天思路清晰、协作顺畅。`,
|
||||
(n: string) => `${n},新的一天值得认真对待,也值得对自己温柔一点。`,
|
||||
(n: string) => `尊敬的${n},把难事先拆小步,今天就会轻松很多。`,
|
||||
(n: string) => `${n}您好,专注当下这一件事,成果会自己跟上。`,
|
||||
];
|
||||
|
||||
export function dailyGreeting(name: string, seed: string) {
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
let h = 0;
|
||||
const s = `${day}:${seed}`;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return GREETINGS[h % GREETINGS.length](name || '同事');
|
||||
}
|
||||
Reference in New Issue
Block a user