From 029342e5392aca12e9502c4c8ba9198e7dba7599 Mon Sep 17 00:00:00 2001 From: salt-fish Date: Tue, 21 Jul 2026 12:18:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E5=88=86=E5=8D=A1=E4=B8=8E=E5=8A=A8=E6=A3=80=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现动画分卡创建、移除与通知去重;支持动检工单合并展示与批量处理;动画工单与动检工单共享查看同组动画区域数据;动画工单中支持主卡与子卡共享查看彼此间的动画人员、计件、备注、状态和产物信息,并且子卡们会按主卡兜底展示前序工序信息。 --- .../event-emitter/event-emitter.service.ts | 2 + .../src/event-emitter/events/core-event.ts | 1 + ...collaborator-notification.listener.spec.ts | 94 + .../collaborator-notification.listener.ts | 9 +- .../form-submission/form-submission.module.ts | 9 +- ...rder-assignment-notification.controller.ts | 47 +- ...er-assignment-notification.service.spec.ts | 159 ++ ...k-order-assignment-notification.service.ts | 675 ++++++- .../src/features/next/next.service.ts | 51 +- .../notification/notification.service.ts | 7 +- .../features/zip-import/zip-import.service.ts | 38 +- apps/nestjs-backend/src/types/cls.ts | 2 + .../WorkOrderAssignmentDialog.tsx | 1579 +++++++++++++---- packages/openapi/src/generate.schema.ts | 1 + ...atch-inspect-work-order-assignment.spec.ts | 24 + .../batch-inspect-work-order-assignment.ts | 51 + .../notification/get-work-order-assignment.ts | 34 + packages/openapi/src/notification/index.ts | 2 + .../split-work-order-assignment.spec.ts | 16 + .../split-work-order-assignment.ts | 94 + 20 files changed, 2515 insertions(+), 380 deletions(-) create mode 100644 apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.spec.ts create mode 100644 apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.service.spec.ts create mode 100644 packages/openapi/src/notification/batch-inspect-work-order-assignment.spec.ts create mode 100644 packages/openapi/src/notification/batch-inspect-work-order-assignment.ts create mode 100644 packages/openapi/src/notification/split-work-order-assignment.spec.ts create mode 100644 packages/openapi/src/notification/split-work-order-assignment.ts diff --git a/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts b/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts index 939b7d25e7..c270dae72d 100644 --- a/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts +++ b/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts @@ -211,6 +211,7 @@ export class EventEmitterService { private createExtendPlainContext(docId: string, id: string) { const user = this.cls.get('user'); const entry = this.cls.get('entry'); + const collaboratorNotificationFieldNames = this.cls.get('collaboratorNotificationFieldNames'); return { baseId: docId, tableId: id.startsWith(IdPrefix.Table) ? id : docId, @@ -220,6 +221,7 @@ export class EventEmitterService { context: { user, entry, + collaboratorNotificationFieldNames, }, }; } diff --git a/apps/nestjs-backend/src/event-emitter/events/core-event.ts b/apps/nestjs-backend/src/event-emitter/events/core-event.ts index b5616f9b8b..7b18fd016d 100644 --- a/apps/nestjs-backend/src/event-emitter/events/core-event.ts +++ b/apps/nestjs-backend/src/event-emitter/events/core-event.ts @@ -19,6 +19,7 @@ export interface IEventContext { name: OpName; propertyKey?: string; }; + collaboratorNotificationFieldNames?: string[]; } export interface IEventRawContext { diff --git a/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.spec.ts b/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.spec.ts new file mode 100644 index 0000000000..f8ead52961 --- /dev/null +++ b/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Events } from '../events'; +import { CollaboratorNotificationListener } from './collaborator-notification.listener'; + +type ITestableListener = { + listener: (event: unknown) => Promise; + fetchUserFields: (tableId: string) => Promise; +}; + +const createListener = () => { + const notificationService = { + sendCollaboratorNotify: vi.fn(), + }; + const recordService = { + getRecordsHeadWithIds: vi.fn().mockResolvedValue([]), + }; + const listener = new CollaboratorNotificationListener( + {} as never, + notificationService as never, + recordService as never, + {} as never + ); + const testableListener = listener as unknown as ITestableListener; + vi.spyOn(testableListener, 'fetchUserFields').mockResolvedValue([ + { + baseId: 'bseTest', + tableName: '动画表', + fieldId: 'fldAnimation', + fieldName: '动画人员', + fieldOptions: JSON.stringify({ shouldNotify: true }), + }, + { + baseId: 'bseTest', + tableName: '动画表', + fieldId: 'fldInspection', + fieldName: '动检人员', + fieldOptions: JSON.stringify({ shouldNotify: true }), + }, + { + baseId: 'bseTest', + tableName: '动画表', + fieldId: 'fldColor', + fieldName: '上色人员', + fieldOptions: JSON.stringify({ shouldNotify: true }), + }, + ]); + return { listener: testableListener, notificationService }; +}; + +const createEvent = (allowedFieldNames?: string[]) => ({ + name: Events.TABLE_RECORD_CREATE, + context: { + user: { id: 'usrOwner', name: 'Owner', email: 'owner@test.com' }, + collaboratorNotificationFieldNames: allowedFieldNames, + }, + payload: { + tableId: 'tblTest', + record: { + id: 'recSplit', + fields: { + fldAnimation: { id: 'usrAnimator', title: 'Animator' }, + fldInspection: { id: 'usrInspector', title: 'Inspector' }, + fldColor: { id: 'usrColorist', title: 'Colorist' }, + }, + }, + }, +}); + +describe('CollaboratorNotificationListener', () => { + it('suppresses all copied user fields when the create whitelist is empty', async () => { + const { listener, notificationService } = createListener(); + + await listener.listener(createEvent([])); + + expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalled(); + }); + + it('notifies only whitelisted user fields for a split record create', async () => { + const { listener, notificationService } = createListener(); + + await listener.listener(createEvent(['动画人员'])); + + expect(notificationService.sendCollaboratorNotify).toHaveBeenCalledTimes(1); + expect(notificationService.sendCollaboratorNotify).toHaveBeenCalledWith( + expect.objectContaining({ + toUserId: 'usrAnimator', + refRecord: expect.objectContaining({ fieldName: '动画人员', recordIds: ['recSplit'] }), + }) + ); + expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalledWith( + expect.objectContaining({ toUserId: 'usrColorist' }) + ); + }); +}); diff --git a/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts index 2fddad997e..0a2c3171e3 100644 --- a/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts +++ b/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts @@ -40,7 +40,14 @@ export class CollaboratorNotificationListener { private async listener(listenerEvent: IListenerEvent): Promise { const { tableId, record } = listenerEvent.payload; - const userFieldData = await this.fetchUserFields(tableId); + let userFieldData = await this.fetchUserFields(tableId); + if ( + listenerEvent.name === Events.TABLE_RECORD_CREATE && + listenerEvent.context.collaboratorNotificationFieldNames + ) { + const allowedNames = new Set(listenerEvent.context.collaboratorNotificationFieldNames); + userFieldData = userFieldData.filter((field) => allowedNames.has(field.fieldName)); + } if (isEmpty(userFieldData)) { return; } diff --git a/apps/nestjs-backend/src/features/form-submission/form-submission.module.ts b/apps/nestjs-backend/src/features/form-submission/form-submission.module.ts index c75adc0964..5dc60ed073 100644 --- a/apps/nestjs-backend/src/features/form-submission/form-submission.module.ts +++ b/apps/nestjs-backend/src/features/form-submission/form-submission.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { FieldOpenApiModule } from '../field/open-api/field-open-api.module'; +import { NotificationModule } from '../notification/notification.module'; import { RecordOpenApiModule } from '../record/open-api/record-open-api.module'; import { RecordModule } from '../record/record.module'; import { TableOpenApiModule } from '../table/open-api/table-open-api.module'; @@ -10,7 +11,13 @@ import { WorkOrderAssignmentNotificationController } from './work-order-assignme import { WorkOrderAssignmentNotificationService } from './work-order-assignment-notification.service'; @Module({ - imports: [FieldOpenApiModule, RecordModule, RecordOpenApiModule, TableOpenApiModule], + imports: [ + FieldOpenApiModule, + NotificationModule, + RecordModule, + RecordOpenApiModule, + TableOpenApiModule, + ], controllers: [FormSubmissionController, WorkOrderAssignmentNotificationController], providers: [ FormSubmissionService, diff --git a/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.controller.ts b/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.controller.ts index 0b289ac188..64bdfc02f1 100644 --- a/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.controller.ts +++ b/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.controller.ts @@ -1,9 +1,15 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; import type { + IBatchInspectWorkOrderAssignmentRo, INotificationWorkOrderAssignmentVo, + ISplitNotificationWorkOrderAssignmentRo, ISubmitNotificationWorkOrderAssignmentRo, } from '@teable/openapi'; -import { submitNotificationWorkOrderAssignmentRoSchema } from '@teable/openapi'; +import { + batchInspectWorkOrderAssignmentRoSchema, + splitNotificationWorkOrderAssignmentRoSchema, + submitNotificationWorkOrderAssignmentRoSchema, +} from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; import type { IClsStore } from '../../types/cls'; import { ZodValidationPipe } from '../../zod.validation.pipe'; @@ -33,4 +39,41 @@ export class WorkOrderAssignmentNotificationController { const currentUserId = this.cls.get('user.id'); return this.assignmentService.submitAssignment(currentUserId, notificationId, submitRo); } + + @Post(':notificationId/work-order-assignment/split') + async splitWorkOrderAssignment( + @Param('notificationId') notificationId: string, + @Body(new ZodValidationPipe(splitNotificationWorkOrderAssignmentRoSchema)) + splitRo: ISplitNotificationWorkOrderAssignmentRo + ): Promise { + const currentUserId = this.cls.get('user.id'); + return this.assignmentService.splitAnimationAssignment(currentUserId, notificationId, splitRo); + } + + @Post(':notificationId/work-order-assignment/inspection/batch') + async batchInspectWorkOrderAssignment( + @Param('notificationId') notificationId: string, + @Body(new ZodValidationPipe(batchInspectWorkOrderAssignmentRoSchema)) + batchRo: IBatchInspectWorkOrderAssignmentRo + ): Promise { + const currentUserId = this.cls.get('user.id'); + return this.assignmentService.batchInspectAnimationAssignments( + currentUserId, + notificationId, + batchRo + ); + } + + @Delete(':notificationId/work-order-assignment/split/:splitRecordId') + async removeWorkOrderAssignmentSplit( + @Param('notificationId') notificationId: string, + @Param('splitRecordId') splitRecordId: string + ): Promise { + const currentUserId = this.cls.get('user.id'); + return this.assignmentService.removeAnimationSplit( + currentUserId, + notificationId, + splitRecordId + ); + } } diff --git a/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.service.spec.ts b/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.service.spec.ts new file mode 100644 index 0000000000..cf8767ec7e --- /dev/null +++ b/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.service.spec.ts @@ -0,0 +1,159 @@ +import { FieldType } from '@teable/core'; +import { describe, expect, it, vi } from 'vitest'; +import { WorkOrderAssignmentNotificationService } from './work-order-assignment-notification.service'; + +type ITestableService = { + buildAnimationSplitFields: ( + resolved: unknown, + splitRo: unknown + ) => Promise>; + buildAnimationAssignmentDisplayFields: ( + context: unknown, + recordId: string, + recordFields: Record, + stage: unknown, + role: 'worker' | 'reviewer' + ) => Promise>; + resolveSplitRootRecordId: (context: unknown) => Promise; +}; + +const createService = () => { + const fieldOpenApiService = { + getFields: vi.fn(), + }; + const recordService = { + getRecord: vi.fn(), + }; + const service = new WorkOrderAssignmentNotificationService( + {} as never, + fieldOpenApiService as never, + recordService as never, + {} as never, + {} as never, + {} as never + ); + return { + service: service as unknown as ITestableService, + fieldOpenApiService, + recordService, + }; +}; + +const createField = (name: string, overrides: Record = {}) => ({ + id: `fld${name}`, + name, + type: FieldType.SingleLineText, + ...overrides, +}); + +describe('WorkOrderAssignmentNotificationService split animation helpers', () => { + it('copies only the split base field whitelist and animation assignee fields', async () => { + const { service, fieldOpenApiService } = createService(); + fieldOpenApiService.getFields.mockResolvedValue([ + createField('序号'), + createField('项目名'), + createField('下发日期'), + createField('分类'), + createField('传票'), + createField('纳期'), + createField('镜头号'), + createField('动检人员', { type: FieldType.User }), + createField('一原人员', { type: FieldType.User }), + createField('动画个人计件', { type: FieldType.Number }), + createField('动画产物', { type: FieldType.Attachment }), + createField('上色人员', { type: FieldType.User }), + createField('不可创建', { recordCreate: false }), + createField('计算传票', { isComputed: true }), + ]); + + const fields = await service.buildAnimationSplitFields( + { + context: { tableId: 'tblTest' }, + stage: { personFieldName: '动画人员', remarkFieldName: '动画备注' }, + record: { + fields: { + 序号: 12, + 项目名: '项目 A', + 下发日期: '2026-07-21', + 分类: 'TV', + 传票: 'V-001', + 纳期: '2026-07-31', + 镜头号: 'cut-001', + 动检人员: { id: 'usrInspector', title: 'Inspector' }, + 一原人员: { id: 'usrKeyAnimator', title: 'Key Animator' }, + 动画个人计件: 8, + 动画产物: [{ token: 'attAnimation' }], + 上色人员: { id: 'usrColorist', title: 'Colorist' }, + 不可创建: 'skip', + 计算传票: 'skip-computed', + }, + }, + }, + { assigneeId: 'usrSplitAnimator', remark: '分卡备注' } + ); + + expect(fields).toEqual({ + 序号: 12, + 项目名: '项目 A', + 下发日期: '2026-07-21', + 分类: 'TV', + 传票: 'V-001', + 纳期: '2026-07-31', + 镜头号: 'cut-001', + 动检人员: { id: 'usrInspector', title: 'Inspector' }, + 动画人员: { id: 'usrSplitAnimator' }, + 动画备注: '分卡备注', + }); + }); + + it('uses root previous-stage values only when child fields are empty', async () => { + const { service, recordService } = createService(); + vi.spyOn(service, 'resolveSplitRootRecordId').mockResolvedValue('recRoot'); + recordService.getRecord.mockResolvedValue({ + id: 'recRoot', + fields: { + 项目名: '项目 A', + 镜头号: 'cut-001', + 下发日期: '2026-07-21', + 一原个人计件: 9, + 一原备注: '主卡一原备注', + 一原产物: [{ token: 'attRoot' }], + 一原结算状态: '已结算', + 二原备注: '主卡二原备注', + 动画个人计件: 99, + }, + }); + const childFields = { + 项目名: '项目 A', + 镜头号: 'cut-001', + 下发日期: '2026-07-21', + 一原个人计件: 0, + 一原备注: '', + 一原产物: [], + 一原结算状态: false, + 二原备注: '子卡二原备注', + 动画个人计件: 1, + }; + + const fields = await service.buildAnimationAssignmentDisplayFields( + { tableId: 'tblTest' }, + 'recChild', + childFields, + { stageName: '动画' }, + 'worker' + ); + + expect(fields).toEqual({ + 项目名: '项目 A', + 镜头号: 'cut-001', + 下发日期: '2026-07-21', + 一原个人计件: 0, + 一原备注: '主卡一原备注', + 一原产物: [{ token: 'attRoot' }], + 一原结算状态: false, + 二原备注: '子卡二原备注', + 动画个人计件: 1, + }); + expect(fields).not.toBe(childFields); + }); +}); diff --git a/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.service.ts b/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.service.ts index cb3dc1b9d0..c773c5214b 100644 --- a/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.service.ts +++ b/apps/nestjs-backend/src/features/form-submission/work-order-assignment-notification.service.ts @@ -13,12 +13,19 @@ import { } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { + IAnimationInspectionCard, + IAnimationSplitCard, + IBatchInspectWorkOrderAssignmentRo, INotificationWorkOrderAssignmentVo, + ISplitNotificationWorkOrderAssignmentRo, ISubmitNotificationWorkOrderAssignmentRo, IWorkOrderAssignmentRole, } from '@teable/openapi'; +import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; +import type { IClsStore } from '../../types/cls'; import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; +import { NotificationService } from '../notification/notification.service'; import { RecordOpenApiService } from '../record/open-api/record-open-api.service'; import { RecordService } from '../record/record.service'; import { @@ -28,8 +35,10 @@ import { type IStartableWorkOrderStage, type ISubmittableWorkOrderStage, isInspectionStage, + PRODUCTION_TEMPLATE_CATEGORY_FIELD_NAME, PRODUCTION_TEMPLATE_PROJECT_FIELD_NAME, PRODUCTION_TEMPLATE_DISPATCH_DATE_FIELD_NAME, + PRODUCTION_TEMPLATE_SEQ_FIELD_NAME, PRODUCTION_TEMPLATE_SHOT_NO_FIELD_NAME, WORK_ORDER_STAGES, type IWorkOrderStage, @@ -50,6 +59,7 @@ type IWorkOrderAssignmentContext = { sourceProcess?: string; shotNo?: string; dispatchDate?: string; + project?: string; eventType?: 'assign' | 'submit' | 'rework' | 'checkOk' | 'review'; // 收件人在这条通知里的角色(issue #159 stage2);缺省 worker,兼容既有通知。 role?: IWorkOrderAssignmentRole; @@ -57,6 +67,7 @@ type IWorkOrderAssignmentContext = { assignedAt: string; taskState: IWorkOrderAssignmentTaskState; taskStateTime?: string; + sourceRecordId?: string; }; type IAssignmentField = { @@ -100,10 +111,27 @@ type IResolvedAssignment = { } | null; }; +type IAnimationGroupRecord = { + recordId: string; + fields: Record; + creatorId?: string; +}; + const workOrderAssignmentKind = 'workOrderAssignment'; const workOrderSubmittedStatus = '已提交'; const workOrderCheckOkStatus = '检查ok'; const workOrderReworkStatus = '返修中'; +const animationSplitCopiedFieldNames = new Set([ + PRODUCTION_TEMPLATE_SEQ_FIELD_NAME, + PRODUCTION_TEMPLATE_PROJECT_FIELD_NAME, + PRODUCTION_TEMPLATE_DISPATCH_DATE_FIELD_NAME, + PRODUCTION_TEMPLATE_CATEGORY_FIELD_NAME, + '传票', + '纳期', + PRODUCTION_TEMPLATE_SHOT_NO_FIELD_NAME, + '动检人员', +]); +const animationPreviousSharedStageNames = new Set(['一原', '一原作监', '二原', '二原作监']); @Injectable() export class WorkOrderAssignmentNotificationService { @@ -111,7 +139,9 @@ export class WorkOrderAssignmentNotificationService { private readonly prismaService: PrismaService, private readonly fieldOpenApiService: FieldOpenApiService, private readonly recordService: RecordService, - private readonly recordOpenApiService: RecordOpenApiService + private readonly recordOpenApiService: RecordOpenApiService, + private readonly notificationService: NotificationService, + private readonly cls: ClsService ) {} async getAssignment( @@ -122,6 +152,143 @@ export class WorkOrderAssignmentNotificationService { return this.buildAssignmentVo(resolved); } + async splitAnimationAssignment( + userId: string, + notificationId: string, + splitRo: ISplitNotificationWorkOrderAssignmentRo + ): Promise { + const resolved = await this.resolveAssignment(userId, notificationId); + this.assertAnimationSplitAssignment(resolved); + + this.assertAnimationSplitInput(userId, splitRo); + + const splitCards = await this.getAnimationSplitCards(resolved); + if (splitCards.some((card) => card.assignee.id === splitRo.assigneeId)) { + throw new CustomHttpException('该人员已有当前镜头的有效分卡', HttpErrorCode.VALIDATION_ERROR); + } + + const childFields = await this.buildAnimationSplitFields(resolved, splitRo); + await this.createAnimationSplitRecord(resolved, userId, splitRo.assigneeId, childFields); + + return this.getAssignment(userId, notificationId); + } + + async removeAnimationSplit( + userId: string, + notificationId: string, + splitRecordId: string + ): Promise { + const resolved = await this.resolveAssignment(userId, notificationId); + this.assertAnimationSplitAssignment(resolved); + + const splitNotification = await this.findAnimationSplitNotification( + userId, + await this.resolveSplitRootRecordId(resolved.context), + splitRecordId + ); + if (!splitNotification) { + throw new CustomHttpException('分卡记录不存在或无权移除', HttpErrorCode.RESTRICTED_RESOURCE); + } + + const splitRecord = await this.recordService.getRecord( + resolved.context.tableId, + splitRecordId, + { fieldKeyType: FieldKeyType.Name }, + false + ); + if (!this.canRemoveAnimationSplit(splitRecord.fields, resolved.stage)) { + throw new CustomHttpException('分卡已进入制作流程,不能移除', HttpErrorCode.VALIDATION_ERROR); + } + + await this.recordOpenApiService.deleteRecord(resolved.context.tableId, splitRecordId); + await this.updateAssignmentNotificationContext(splitNotification, { + taskState: 'expired', + taskStateTime: new Date().toISOString(), + isRead: true, + }); + + return this.getAssignment(userId, notificationId); + } + + async batchInspectAnimationAssignments( + userId: string, + notificationId: string, + batchRo: IBatchInspectWorkOrderAssignmentRo + ): Promise { + const resolved = await this.resolveAssignment(userId, notificationId); + if (!this.canBatchInspectAnimation(resolved)) { + throw new CustomHttpException( + '当前任务不支持动画批量动检', + HttpErrorCode.RESTRICTED_RESOURCE + ); + } + + const recordIds = [...new Set(batchRo.recordIds)]; + if (recordIds.length !== batchRo.recordIds.length) { + throw new CustomHttpException('批量动检记录不能重复', HttpErrorCode.VALIDATION_ERROR); + } + + const cards = await this.getAnimationInspectionCards(resolved); + if (cards.length < 2) { + throw new CustomHttpException( + '当前镜头没有可批量处理的动画分卡', + HttpErrorCode.VALIDATION_ERROR + ); + } + const eligibleRecordIds = new Set( + cards.filter((card) => card.canProcess).map((card) => card.recordId) + ); + if (recordIds.some((recordId) => !eligibleRecordIds.has(recordId))) { + throw new CustomHttpException( + '包含无权处理或未提交的动画记录', + HttpErrorCode.RESTRICTED_RESOURCE + ); + } + + for (let index = 0; index < recordIds.length; index++) { + const recordId = recordIds[index]!; + const record = await this.recordService.getRecord( + resolved.context.tableId, + recordId, + { fieldKeyType: FieldKeyType.Name }, + false + ); + const fields: Record = {}; + const remark = batchRo.remarks?.[recordId]; + if (remark !== undefined && resolved.stage.remarkFieldName) { + fields[resolved.stage.remarkFieldName] = remark; + } + if (batchRo.action === 'start') { + fields[this.getStartStatusFieldName(resolved.stage)] = resolved.stage.startStatus; + } else { + this.assertInspectionStage(resolved.stage); + fields[resolved.stage.inspectionStatusFieldName] = + batchRo.action === 'checkOk' ? workOrderCheckOkStatus : workOrderReworkStatus; + if (batchRo.action === 'rework') { + const previousStage = this.getPreviousStage(resolved.stage); + if (previousStage?.productionStatusFieldName) { + fields[previousStage.productionStatusFieldName] = workOrderReworkStatus; + } + } + } + await this.recordOpenApiService.updateRecord(resolved.context.tableId, record.id, { + fieldKeyType: FieldKeyType.Name, + typecast: true, + record: { fields }, + }); + } + + if (batchRo.action !== 'start') { + await this.updateAssignmentNotificationContext(resolved.notification, { + taskState: 'submitted', + taskStateTime: new Date().toISOString(), + isRead: true, + }); + } + + return this.getAssignment(userId, notificationId); + } + async submitAssignment( userId: string, notificationId: string, @@ -343,8 +510,15 @@ export class WorkOrderAssignmentNotificationService { } const role = context.role ?? 'worker'; + const displayRecordFields = await this.buildAnimationAssignmentDisplayFields( + context, + record.id, + record.fields, + stage, + role + ); const allStages = WORK_ORDER_STAGES.map((item) => - this.buildAssignmentSection(item, fields, record.fields, stage, role) + this.buildAssignmentSection(item, fields, displayRecordFields, stage, role) ); const currentStage = allStages.find((item) => item.process === stage.stageName) ?? @@ -424,8 +598,20 @@ export class WorkOrderAssignmentNotificationService { if (!context || context.kind !== workOrderAssignmentKind) { return null; } - const { tableId, recordId, process, fieldName, assignedAt, taskState, taskStateTime, role } = - context; + const { + tableId, + recordId, + process, + fieldName, + assignedAt, + taskState, + taskStateTime, + sourceRecordId, + role, + shotNo, + dispatchDate, + project, + } = context; if ( typeof tableId !== 'string' || typeof recordId !== 'string' || @@ -445,6 +631,10 @@ export class WorkOrderAssignmentNotificationService { assignedAt, taskState: this.normalizeTaskState(taskState), taskStateTime: typeof taskStateTime === 'string' ? taskStateTime : undefined, + sourceRecordId: typeof sourceRecordId === 'string' ? sourceRecordId : undefined, + shotNo: typeof shotNo === 'string' ? shotNo : undefined, + dispatchDate: typeof dispatchDate === 'string' ? dispatchDate : undefined, + project: typeof project === 'string' ? project : undefined, // issue #159 stage2:审核视角标记,缺省 worker(既有通知无此字段)。 role: role === 'reviewer' ? 'reviewer' : 'worker', }; @@ -457,6 +647,37 @@ export class WorkOrderAssignmentNotificationService { return value === 'submitted' || value === 'expired' ? value : 'pending'; } + private async resolveSplitRootRecordId(context: IWorkOrderAssignmentContext): Promise { + let rootRecordId = context.sourceRecordId ?? context.recordId; + const visitedRecordIds = new Set([context.recordId]); + + // Older split notifications point to their direct parent. Follow that chain on read, + // while every newly-created split writes the normalized root record ID. + for (let depth = 0; depth < 20 && !visitedRecordIds.has(rootRecordId); depth++) { + visitedRecordIds.add(rootRecordId); + const notifications = await this.prismaService.notification.findMany({ + where: { + messageI18n: { contains: `"recordId":"${rootRecordId}"` }, + }, + select: { messageI18n: true }, + }); + const parentContext = notifications + .map((notification) => this.parseAssignmentContext(notification.messageI18n)) + .find( + (candidate) => + candidate?.tableId === context.tableId && + candidate.recordId === rootRecordId && + candidate.process === '动画' + ); + if (!parentContext?.sourceRecordId) { + break; + } + rootRecordId = parentContext.sourceRecordId; + } + + return rootRecordId; + } + private resolveStage(process: string): IWorkOrderStage { const stage = WORK_ORDER_STAGES.find((item) => item.stageName === process); if (!stage) { @@ -595,6 +816,57 @@ export class WorkOrderAssignmentNotificationService { }; } + private async buildAnimationAssignmentDisplayFields( + context: IWorkOrderAssignmentContext, + recordId: string, + recordFields: Record, + stage: IWorkOrderStage, + role: IWorkOrderAssignmentRole + ) { + if (stage.stageName !== '动画' || role === 'reviewer') { + return recordFields; + } + + const rootRecordId = await this.resolveSplitRootRecordId(context); + if (rootRecordId === recordId) { + return recordFields; + } + + try { + const rootRecord = await this.recordService.getRecord( + context.tableId, + rootRecordId, + { fieldKeyType: FieldKeyType.Name }, + false + ); + if (!this.hasSameWorkOrderIdentity(rootRecord.fields, recordFields)) { + return recordFields; + } + + const displayFields = { ...recordFields }; + for (const previousStage of WORK_ORDER_STAGES) { + if (!animationPreviousSharedStageNames.has(previousStage.stageName)) { + continue; + } + for (const fieldName of previousStage.sectionFieldNames) { + if ( + this.isEmptyCellValue(displayFields[fieldName]) && + !this.isEmptyCellValue(rootRecord.fields[fieldName]) + ) { + displayFields[fieldName] = rootRecord.fields[fieldName]; + } + } + } + return displayFields; + } catch { + return recordFields; + } + } + + private isEmptyCellValue(value: unknown) { + return value == null || value === '' || (Array.isArray(value) && value.length === 0); + } + private getUserIds(value: unknown): string[] { if (!value) { return []; @@ -644,20 +916,34 @@ export class WorkOrderAssignmentNotificationService { toUserId: string, tableId: string, recordId: string, - stage: IWorkOrderStage + stage: IWorkOrderStage, + sourceRecordId?: string, + identity?: { project?: string; shotNo?: string; dispatchDate?: string } ) { - return this.prismaService.notification.findFirst({ + const notifications = await this.prismaService.notification.findMany({ where: { toUserId, AND: [ { messageI18n: { contains: `"kind":"${workOrderAssignmentKind}"` } }, { messageI18n: { contains: `"tableId":"${tableId}"` } }, - { messageI18n: { contains: `"recordId":"${recordId}"` } }, { messageI18n: { contains: `"process":"${stage.stageName}"` } }, ], }, orderBy: { createdTime: 'desc' }, - select: { id: true }, + select: { id: true, messageI18n: true }, + }); + return notifications.find((notification) => { + const context = this.parseAssignmentContext(notification.messageI18n); + if (!context) return false; + if (!sourceRecordId) return context.recordId === recordId; + return ( + context.sourceRecordId === sourceRecordId || + (!context.sourceRecordId && context.recordId === sourceRecordId) || + (Boolean(identity?.project && identity?.shotNo && identity?.dispatchDate) && + context.project === identity?.project && + context.shotNo === identity?.shotNo && + context.dispatchDate === identity?.dispatchDate) + ); }); } @@ -687,7 +973,15 @@ export class WorkOrderAssignmentNotificationService { params.toUserId, params.tableId, params.recordId, - params.stage + params.stage, + typeof params.extraContext?.sourceRecordId === 'string' + ? params.extraContext.sourceRecordId + : undefined, + { + project: params.extraContext?.project as string | undefined, + shotNo: params.extraContext?.shotNo as string | undefined, + dispatchDate: params.extraContext?.dispatchDate as string | undefined, + } ); if (existing) { @@ -799,12 +1093,13 @@ export class WorkOrderAssignmentNotificationService { return; } + const sourceRecordId = await this.resolveSplitRootRecordId(resolved.context); await this.upsertAssignmentNotification({ fromUserId: resolved.notification.toUserId, toUserId: assignee.id, tableId: resolved.context.tableId, baseId: resolved.baseId, - recordId: resolved.context.recordId, + recordId: sourceRecordId, stage, message, assignedAt, @@ -818,6 +1113,9 @@ export class WorkOrderAssignmentNotificationService { dispatchDate: this.toText(resolved.record.fields[PRODUCTION_TEMPLATE_DISPATCH_DATE_FIELD_NAME]) || undefined, + project: + this.toText(resolved.record.fields[PRODUCTION_TEMPLATE_PROJECT_FIELD_NAME]) || undefined, + sourceRecordId, }, }); } @@ -891,15 +1189,32 @@ export class WorkOrderAssignmentNotificationService { }); } - private buildAssignmentVo(resolved: IResolvedAssignment): INotificationWorkOrderAssignmentVo { + private async buildAssignmentVo( + resolved: IResolvedAssignment + ): Promise { const currentAssignee = this.pickSingleUser( resolved.record.fields[resolved.stage.personFieldName] ); const project = this.toText(resolved.record.fields[PRODUCTION_TEMPLATE_PROJECT_FIELD_NAME]); const shotNo = this.toText(resolved.record.fields[PRODUCTION_TEMPLATE_SHOT_NO_FIELD_NAME]); + const canViewAnimationSplits = this.canViewAnimationSplits(resolved); + const canManageAnimationSplits = + canViewAnimationSplits && this.canManageAnimationSplits(resolved); + const canSplitAnimation = canManageAnimationSplits; + const animationSplitCards = canViewAnimationSplits + ? await this.getAnimationSplitCards(resolved, canManageAnimationSplits) + : []; + const supportsBatchAnimationInspection = this.canBatchInspectAnimation(resolved); + const animationInspectionCards = supportsBatchAnimationInspection + ? await this.getAnimationInspectionCards(resolved) + : []; + const canBatchInspectAnimation = + supportsBatchAnimationInspection && animationInspectionCards.length > 1; + return { notificationId: resolved.notification.id, + baseId: resolved.baseId, tableId: resolved.context.tableId, tableName: resolved.tableName, recordId: resolved.context.recordId, @@ -922,9 +1237,347 @@ export class WorkOrderAssignmentNotificationService { previousStages: resolved.previousStages, allStages: resolved.allStages, existingArtifacts: resolved.existingArtifacts, + canSplitAnimation, + canViewAnimationSplits, + canManageAnimationSplits, + animationSplitCards, + canBatchInspectAnimation, + animationInspectionCards, + }; + } + + private canViewAnimationSplits(resolved: IResolvedAssignment) { + return ( + resolved.stage.stageName === '动画' && + resolved.context.role !== 'reviewer' && + resolved.effectiveTaskState !== 'expired' && + canStageSubmit(resolved.stage) + ); + } + + private canManageAnimationSplits(resolved: IResolvedAssignment) { + return resolved.effectiveTaskState === 'pending' && this.canViewAnimationSplits(resolved); + } + + private canBatchInspectAnimation(resolved: IResolvedAssignment) { + return ( + resolved.stage.stageName === '动检' && + resolved.context.role !== 'reviewer' && + resolved.effectiveTaskState !== 'expired' + ); + } + + private assertAnimationSplitAssignment(resolved: IResolvedAssignment) { + if ( + resolved.stage.stageName !== '动画' || + resolved.context.role === 'reviewer' || + resolved.effectiveTaskState === 'expired' || + !canStageSubmit(resolved.stage) + ) { + throw new CustomHttpException('当前任务不支持动画分卡', HttpErrorCode.RESTRICTED_RESOURCE); + } + } + + private assertAnimationSplitInput( + userId: string, + splitRo: ISplitNotificationWorkOrderAssignmentRo + ) { + if (splitRo.assigneeId === userId) { + throw new CustomHttpException('不能将动画分卡给自己', HttpErrorCode.VALIDATION_ERROR); + } + } + + private async buildAnimationSplitFields( + resolved: IResolvedAssignment, + splitRo: ISplitNotificationWorkOrderAssignmentRo + ) { + const fields = await this.fieldOpenApiService.getFields(resolved.context.tableId, { + filterHidden: false, + }); + const childFields: Record = {}; + + for (const field of fields) { + if ( + field.recordCreate === false || + field.isComputed || + field.isLookup || + !animationSplitCopiedFieldNames.has(field.name) + ) { + continue; + } + const value = resolved.record.fields[field.name]; + if (value !== undefined) { + childFields[field.name] = value; + } + } + + childFields[resolved.stage.personFieldName] = { id: splitRo.assigneeId }; + if (resolved.stage.remarkFieldName) { + childFields[resolved.stage.remarkFieldName] = splitRo.remark ?? ''; + } + return childFields; + } + + private async createAnimationSplitRecord( + resolved: IResolvedAssignment, + userId: string, + assigneeId: string, + childFields: Record + ) { + let splitRecordId: string | undefined; + const sourceRecordId = await this.resolveSplitRootRecordId(resolved.context); + const previousNotificationFields = this.cls.get('collaboratorNotificationFieldNames'); + this.cls.set('collaboratorNotificationFieldNames', []); + try { + const created = await this.recordOpenApiService.createRecords(resolved.context.tableId, { + fieldKeyType: FieldKeyType.Name, + typecast: true, + records: [{ fields: childFields }], + }); + splitRecordId = created.records[0]?.id; + if (!splitRecordId) { + throw new CustomHttpException('创建分卡记录失败', HttpErrorCode.VALIDATION_ERROR); + } + await this.notificationService.sendCollaboratorNotify({ + fromUserId: userId, + toUserId: assigneeId, + refRecord: { + baseId: resolved.baseId, + tableId: resolved.context.tableId, + tableName: resolved.tableName, + fieldName: resolved.stage.personFieldName, + recordIds: [splitRecordId], + recordTitles: [], + }, + workOrderAssignmentExtraContext: { sourceRecordId }, + }); + } catch (error) { + if (splitRecordId) { + await this.recordOpenApiService.deleteRecord(resolved.context.tableId, splitRecordId); + } + throw error; + } finally { + this.cls.set('collaboratorNotificationFieldNames', previousNotificationFields); + } + } + + private async getAnimationSplitCards( + resolved: IResolvedAssignment, + canManage = this.canManageAnimationSplits(resolved) + ): Promise { + const animationStage = this.resolveStage('动画'); + const records = await this.getAnimationGroupRecords(resolved); + return records + .map((record) => this.buildAnimationSplitCard(resolved, animationStage, record, canManage)) + .filter((card): card is IAnimationSplitCard => card !== undefined); + } + + private async getAnimationInspectionCards( + resolved: IResolvedAssignment + ): Promise { + const animationStage = this.resolveStage('动画'); + const cards = await Promise.all( + (await this.getAnimationGroupRecords(resolved)).map( + async (record): Promise => { + const animation = this.buildAnimationSplitCard(resolved, animationStage, record, false); + const inspectionStatus = resolved.stage.inspectionStatusFieldName + ? this.toText(record.fields[resolved.stage.inspectionStatusFieldName]) + : ''; + if ( + !animation || + (animation.productionStatus !== workOrderSubmittedStatus && !inspectionStatus) + ) { + return undefined; + } + const isProcessed = + inspectionStatus === workOrderCheckOkStatus || + inspectionStatus === workOrderReworkStatus; + return { + recordId: record.recordId, + animation, + inspectionRemark: resolved.stage.remarkFieldName + ? this.toText(record.fields[resolved.stage.remarkFieldName]) + : '', + ...(inspectionStatus ? { inspectionStatus } : {}), + isProcessed, + canProcess: Boolean( + this.getUserIds(record.fields[resolved.stage.personFieldName]).includes( + resolved.notification.toUserId + ) && !isProcessed + ), + }; + } + ) + ); + return cards.filter((card): card is IAnimationInspectionCard => card !== undefined); + } + + private async getAnimationGroupRecords( + resolved: IResolvedAssignment + ): Promise { + const rootRecordId = await this.resolveSplitRootRecordId(resolved.context); + const rootRecord = + rootRecordId === resolved.record.id + ? resolved.record + : await this.recordService.getRecord( + resolved.context.tableId, + rootRecordId, + { fieldKeyType: FieldKeyType.Name }, + false + ); + const notifications = await this.prismaService.notification.findMany({ + where: { + messageI18n: { + contains: `"kind":"${workOrderAssignmentKind}"`, + }, + AND: [ + { messageI18n: { contains: `"tableId":"${resolved.context.tableId}"` } }, + { messageI18n: { contains: `"process":"动画"` } }, + ], + }, + select: { id: true, fromUserId: true, messageI18n: true }, + }); + const splitCreatorByRecordId = new Map(); + for (const notification of notifications) { + const context = this.parseAssignmentContext(notification.messageI18n); + if (context?.process === '动画') { + splitCreatorByRecordId.set(context.recordId, notification.fromUserId); + } + } + + const recordIds = [ + rootRecordId, + ...[...splitCreatorByRecordId.keys()].filter((recordId) => recordId !== rootRecordId), + ]; + const records = await Promise.all( + recordIds.map(async (recordId): Promise => { + try { + const record = await this.recordService.getRecord( + resolved.context.tableId, + recordId, + { fieldKeyType: FieldKeyType.Name }, + false + ); + if (!this.hasSameWorkOrderIdentity(rootRecord.fields, record.fields)) { + return undefined; + } + return { + recordId, + fields: record.fields, + creatorId: splitCreatorByRecordId.get(recordId), + }; + } catch { + return undefined; + } + }) + ); + + return records.filter((record): record is IAnimationGroupRecord => record !== undefined); + } + + private buildAnimationSplitCard( + resolved: IResolvedAssignment, + animationStage: IWorkOrderStage, + record: IAnimationGroupRecord, + canManage: boolean + ): IAnimationSplitCard | undefined { + const assignee = this.pickSingleUser(record.fields[animationStage.personFieldName]); + if (!assignee?.id || !animationStage.pieceworkFieldName) { + return undefined; + } + const artifacts = animationStage.artifactFieldName + ? this.getAttachmentValue(record.fields[animationStage.artifactFieldName]) + : []; + const productionStatus = animationStage.productionStatusFieldName + ? this.toText(record.fields[animationStage.productionStatusFieldName]) + : ''; + return { + recordId: record.recordId, + assignee: { + id: assignee.id, + name: assignee.title, + avatarUrl: assignee.avatarUrl ?? null, + }, + piecework: this.toNumber(record.fields[animationStage.pieceworkFieldName]), + remark: animationStage.remarkFieldName + ? this.toText(record.fields[animationStage.remarkFieldName]) + : '', + productionStatus: productionStatus || undefined, + hasArtifacts: artifacts.length > 0, + artifacts, + isCurrent: record.recordId === resolved.record.id, + canRemove: + canManage && + record.creatorId === resolved.notification.toUserId && + this.canRemoveAnimationSplit(record.fields, animationStage), }; } + private hasSameWorkOrderIdentity( + rootFields: Record, + candidateFields: Record + ) { + return [ + PRODUCTION_TEMPLATE_PROJECT_FIELD_NAME, + PRODUCTION_TEMPLATE_SHOT_NO_FIELD_NAME, + PRODUCTION_TEMPLATE_DISPATCH_DATE_FIELD_NAME, + ].every( + (fieldName) => this.toText(rootFields[fieldName]) === this.toText(candidateFields[fieldName]) + ); + } + + private async findAnimationSplitNotification( + fromUserId: string, + sourceRecordId: string, + splitRecordId: string + ) { + const notifications = await this.prismaService.notification.findMany({ + where: { + fromUserId, + messageI18n: { contains: `"sourceRecordId":"${sourceRecordId}"` }, + }, + select: { id: true, messageI18n: true }, + }); + return notifications.find((notification) => { + const context = this.parseAssignmentContext(notification.messageI18n); + return context?.sourceRecordId === sourceRecordId && context.recordId === splitRecordId; + }); + } + + private async findAssignmentNotificationForRecord( + userId: string, + tableId: string, + recordId: string, + stage: IWorkOrderStage + ) { + const notifications = await this.prismaService.notification.findMany({ + where: { + toUserId: userId, + messageI18n: { contains: `"recordId":"${recordId}"` }, + }, + select: { id: true, messageI18n: true }, + }); + return notifications.find((notification) => { + const context = this.parseAssignmentContext(notification.messageI18n); + return ( + context?.tableId === tableId && + context.recordId === recordId && + context.process === stage.stageName && + context.taskState === 'pending' + ); + }); + } + + private canRemoveAnimationSplit(fields: Record, stage: IWorkOrderStage) { + const hasArtifacts = stage.artifactFieldName + ? this.getAttachmentValue(fields[stage.artifactFieldName]).length > 0 + : false; + const productionStatus = stage.productionStatusFieldName + ? this.toText(fields[stage.productionStatusFieldName]) + : ''; + return !hasArtifacts && !productionStatus; + } + private pickSingleUser(value: unknown): IUserCellValue | null { if (!value) { return null; diff --git a/apps/nestjs-backend/src/features/next/next.service.ts b/apps/nestjs-backend/src/features/next/next.service.ts index 85b63a5c60..a58ad51d93 100644 --- a/apps/nestjs-backend/src/features/next/next.service.ts +++ b/apps/nestjs-backend/src/features/next/next.service.ts @@ -7,29 +7,56 @@ import createServer from 'next'; import { CacheService } from '../../cache/cache.service'; import type { ICacheStore } from '../../cache/types'; +type INextServer = ReturnType; + +type INextServerState = { + server?: INextServer; + startup?: Promise; +}; + +// HMR can briefly create multiple Nest application instances in one process. +// Keep the embedded Next server shared so those instances do not race for Next's dev lock. +const nextServerState = (() => { + const runtime = globalThis as typeof globalThis & { + __teableNextServerState?: INextServerState; + }; + return (runtime.__teableNextServerState ??= {}); +})(); + @Injectable() export class NextService implements OnModuleInit, OnModuleDestroy { private logger = new Logger(NextService.name); - public server!: ReturnType; + public server!: INextServer; constructor( private configService: ConfigService, private readonly cacheService: CacheService ) {} private async startNEXTjs() { + if (nextServerState.server) { + this.server = nextServerState.server; + return; + } + const nodeEnv = this.configService.get('NODE_ENV'); const port = this.configService.get('PORT'); const nextJsDir = this.configService.get('NEXTJS_DIR'); try { - this.server = createServer({ - dev: nodeEnv !== 'production', - port: port, - dir: nextJsDir, - hostname: 'localhost', - turbopack: true, - }); - await this.server.prepare(); + nextServerState.startup ??= (async () => { + const server = createServer({ + dev: nodeEnv !== 'production', + port: port, + dir: nextJsDir, + hostname: 'localhost', + turbopack: true, + }); + await server.prepare(); + nextServerState.server = server; + return server; + })(); + this.server = await nextServerState.startup; } catch (error) { + nextServerState.startup = undefined; this.logger.error(error); } } @@ -41,7 +68,11 @@ export class NextService implements OnModuleInit, OnModuleDestroy { } async onModuleDestroy() { - await this.server?.close(); + if (process.env.NODE_ENV === 'production' && this.server === nextServerState.server) { + await this.server.close(); + nextServerState.server = undefined; + nextServerState.startup = undefined; + } } async saveQueryParams(queryParamsRo: IQueryParamsRo): Promise { diff --git a/apps/nestjs-backend/src/features/notification/notification.service.ts b/apps/nestjs-backend/src/features/notification/notification.service.ts index 2b8d82d265..21463b17d9 100644 --- a/apps/nestjs-backend/src/features/notification/notification.service.ts +++ b/apps/nestjs-backend/src/features/notification/notification.service.ts @@ -80,6 +80,7 @@ type IWorkOrderAssignmentContext = { assignedAt: string; taskState: IWorkOrderAssignmentTaskState; taskStateTime?: string; + sourceRecordId?: string; }; const workOrderAssignmentKind = 'workOrderAssignment'; @@ -150,8 +151,9 @@ export class NotificationService { recordIds: string[]; recordTitles: { id: string; title: string }[]; }; + workOrderAssignmentExtraContext?: Record; }): Promise { - const { fromUserId, toUserId, refRecord } = params; + const { fromUserId, toUserId, refRecord, workOrderAssignmentExtraContext } = params; const [fromUser, toUser] = await Promise.all([ this.userService.getUserById(fromUserId), this.userService.getUserById(toUserId), @@ -186,6 +188,7 @@ export class NotificationService { tableId: refRecord.tableId, fieldName: refRecord.fieldName, recordIds: refRecord.recordIds, + extraContext: workOrderAssignmentExtraContext, }); let message: string | ILocalization = ''; @@ -798,6 +801,7 @@ export class NotificationService { tableId: string; fieldName: string; recordIds: string[]; + extraContext?: Record; }): Promise | undefined> { if (params.recordIds.length !== 1) { return undefined; @@ -839,6 +843,7 @@ export class NotificationService { assignedAt: new Date().toISOString(), taskState: 'pending', taskStateTime: new Date().toISOString(), + ...params.extraContext, } satisfies IWorkOrderAssignmentContext; } diff --git a/apps/nestjs-backend/src/features/zip-import/zip-import.service.ts b/apps/nestjs-backend/src/features/zip-import/zip-import.service.ts index 2bacf9a611..ff4b55ddc9 100644 --- a/apps/nestjs-backend/src/features/zip-import/zip-import.service.ts +++ b/apps/nestjs-backend/src/features/zip-import/zip-import.service.ts @@ -16,11 +16,7 @@ import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; import { RecordOpenApiService } from '../record/open-api/record-open-api.service'; import { DEFAULT_VIEWS } from '../table/constant'; import { TableOpenApiService } from '../table/open-api/table-open-api.service'; -import { - deriveCategory, - deriveDispatchDate, - estimateShotCount, -} from './autofill'; +import { deriveCategory, deriveDispatchDate, estimateShotCount } from './autofill'; import { scanDelivery } from './parser/delivery-scanner'; import type { ShotFile, ShotFolder } from './parser/delivery-types'; import { @@ -49,7 +45,6 @@ export interface ZipImportSummary { export interface ZipImportPreviewField { name: string; type: IFieldRo['type']; - isPrimary?: boolean; options?: IFieldRo['options']; } @@ -76,10 +71,12 @@ interface IAnalyzedZipImport { shots: ShotFolder[]; dispatchDate: string; category: string; - sourceFields: IFieldRo[]; + sourceFields: INamedFieldRo[]; attachmentFileCount: number; } +type INamedFieldRo = IFieldRo & { name: string }; + const RECORD_BATCH_SIZE = 50; @Injectable() @@ -101,7 +98,6 @@ export class ZipImportService { sourceFields: analyzed.sourceFields.map((field) => ({ name: field.name, type: field.type, - isPrimary: field.isPrimary, options: field.options, })), })); @@ -115,7 +111,9 @@ export class ZipImportService { ): Promise { const targetTable = await this.tableOpenApiService.getTable(baseId, tableId); return this.withAnalyzedArchive(zipBuffer, async (analyzed) => { - const existingFields = await this.fieldOpenApiService.getFields(tableId, { filterHidden: false }); + const existingFields = await this.fieldOpenApiService.getFields(tableId, { + filterHidden: false, + }); const targetFieldIdBySourceName = await this.prepareTargetFields( tableId, importRo.viewId, @@ -226,7 +224,7 @@ export class ZipImportService { shots, dispatchDate, category, - sourceFields: buildShotTableFields(episodes), + sourceFields: this.requireFieldNames(buildShotTableFields(episodes)), attachmentFileCount: this.countAttachmentFiles(shots), }); } finally { @@ -403,14 +401,14 @@ export class ZipImportService { private async prepareTargetFields( tableId: string, viewId: string | undefined, - sourceFields: IFieldRo[], + sourceFields: INamedFieldRo[], existingFields: Array<{ id: string; name: string }>, rawMappings: ZipImportFieldMapping[] ): Promise> { const sourceFieldByName = new Map(sourceFields.map((field) => [field.name, field])); const existingFieldById = new Map(existingFields.map((field) => [field.id, field])); const existingFieldNames = new Set(existingFields.map((field) => field.name)); - const mappings = sourceFields.map((field) => { + const mappings: ZipImportFieldMapping[] = sourceFields.map((field) => { const mapping = rawMappings.find((item) => item.sourceFieldName === field.name); return mapping ?? { sourceFieldName: field.name, action: 'discard' as const }; }); @@ -445,8 +443,10 @@ export class ZipImportService { if (left !== right) { return left - right; } - return sourceFields.findIndex((field) => field.name === a.sourceFieldName) - - sourceFields.findIndex((field) => field.name === b.sourceFieldName); + return ( + sourceFields.findIndex((field) => field.name === a.sourceFieldName) - + sourceFields.findIndex((field) => field.name === b.sourceFieldName) + ); }); let insertedSoFar = 0; @@ -467,7 +467,6 @@ export class ZipImportService { const createdField = await this.fieldOpenApiService.createField(tableId, { ...sourceField, - isPrimary: false, viewId, order: viewId && mapping.insertAfterOrderIndex != null @@ -485,6 +484,15 @@ export class ZipImportService { return targetFieldIdBySourceName; } + private requireFieldNames(fields: IFieldRo[]): INamedFieldRo[] { + return fields.map((field) => { + if (!field.name) { + throw new CustomHttpException('ZIP 导入源字段缺少名称', HttpErrorCode.VALIDATION_ERROR); + } + return field as INamedFieldRo; + }); + } + private mapSourceRecordsToTargetRecords( sourceRecords: { fields: Record }[], targetFieldIdBySourceName: Map diff --git a/apps/nestjs-backend/src/types/cls.ts b/apps/nestjs-backend/src/types/cls.ts index 06a2edd6f8..99d4abd6ab 100644 --- a/apps/nestjs-backend/src/types/cls.ts +++ b/apps/nestjs-backend/src/types/cls.ts @@ -86,6 +86,8 @@ export interface IClsStore extends ClsStore { v2Feature?: V2Feature; // The feature name that triggered V2 check windowId?: string; // Window ID from x-window-id header for undo/redo tracking skipFieldComputation?: boolean; // Skip computed field evaluation during bulk structure creation (import/duplicate) + // Restrict collaborator notifications emitted by records created in this request scope. + collaboratorNotificationFieldNames?: string[]; // cache for base share node tree (to avoid repeated queries within same request) baseShareNodeCache?: Map< string, diff --git a/apps/nextjs-app/src/features/app/components/notifications/WorkOrderAssignmentDialog.tsx b/apps/nextjs-app/src/features/app/components/notifications/WorkOrderAssignmentDialog.tsx index f995f747c4..d0262f52c6 100644 --- a/apps/nextjs-app/src/features/app/components/notifications/WorkOrderAssignmentDialog.tsx +++ b/apps/nextjs-app/src/features/app/components/notifications/WorkOrderAssignmentDialog.tsx @@ -1,9 +1,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { IAttachmentCellValue, IAttachmentItem, IUserCellValue } from '@teable/core'; import { FieldType } from '@teable/core'; -import { ChevronLeft, ChevronRight } from '@teable/icons'; +import { Check, ChevronLeft, ChevronRight, ChevronsUpDown } from '@teable/icons'; import { + batchInspectWorkOrderAssignment, getNotificationWorkOrderAssignment, + getUserCollaborators, + removeNotificationWorkOrderAssignmentSplit, + splitNotificationWorkOrderAssignment, submitNotificationWorkOrderAssignment, type IWorkOrderAssignmentAction, type INotificationWorkOrderAssignmentVo, @@ -22,7 +26,20 @@ import { FilePreviewProvider, isImage, } from '@teable/ui-lib'; -import { cn, Input, Textarea } from '@teable/ui-lib/shadcn'; +import { + cn, + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, + Checkbox, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Textarea, +} from '@teable/ui-lib/shadcn'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import { useEffect, useMemo, useState } from 'react'; import { @@ -224,6 +241,144 @@ const getStageProductionStatusField = (section?: IWorkOrderAssignmentSection) => const getStageInspectionStatusField = (section?: IWorkOrderAssignmentSection) => section?.fields.find((field) => field.name === `${section?.process}状态`); +type IWorkOrderAssignmentField = IWorkOrderAssignmentSection['fields'][number]; + +const getArtifactValue = ( + artifactField: IWorkOrderAssignmentField | undefined, + isCurrentStage: boolean, + detail: INotificationWorkOrderAssignmentVo +) => { + if (!artifactField) { + return undefined; + } + return isCurrentStage ? detail.existingArtifacts : artifactField.value; +}; + +const getVisibleStageFields = ({ + selectedSection, + artifactField, + pieceworkField, + remarkField, + isCurrentStage, +}: { + selectedSection?: IWorkOrderAssignmentSection; + artifactField?: IWorkOrderAssignmentField; + pieceworkField?: IWorkOrderAssignmentField; + remarkField?: IWorkOrderAssignmentField; + isCurrentStage: boolean; +}) => { + return ( + selectedSection?.fields.filter((field) => { + const isArtifactField = artifactField?.name === field.name; + const isCurrentPieceworkField = isCurrentStage && pieceworkField?.name === field.name; + const isCurrentRemarkField = isCurrentStage && remarkField?.name === field.name; + return !isArtifactField && !isCurrentPieceworkField && !isCurrentRemarkField; + }) ?? [] + ); +}; + +const CurrentStageHint = ({ + artifactField, + pieceworkField, + remarkField, +}: { + artifactField?: IWorkOrderAssignmentField; + pieceworkField?: IWorkOrderAssignmentField; + remarkField?: IWorkOrderAssignmentField; +}) => { + if (pieceworkField || artifactField) { + return ( +
+ 提交后会更新时间与制作状态,已提交后也可以继续追加附件。 +
+ ); + } + + if (remarkField) { + return ( +
执行当前操作时会同步保存本工序备注。
+ ); + } + + return null; +}; + +const CurrentStageEditableFields = ({ + detail, + piecework, + remark, + attachments, + attachmentManager, + artifactField, + pieceworkField, + remarkField, + hideCurrentRemark, + onPieceworkChange, + onRemarkChange, + onAttachmentsChange, +}: { + detail: INotificationWorkOrderAssignmentVo; + piecework: string; + remark: string; + attachments: IAttachmentCellValue; + attachmentManager: AttachmentManager; + artifactField?: IWorkOrderAssignmentField; + pieceworkField?: IWorkOrderAssignmentField; + remarkField?: IWorkOrderAssignmentField; + hideCurrentRemark: boolean; + onPieceworkChange: (value: string) => void; + onRemarkChange: (value: string) => void; + onAttachmentsChange: (value: IAttachmentCellValue) => void; +}) => ( + <> + {artifactField ? ( +
+
追加产物
+ onAttachmentsChange(value ?? [])} + attachmentManager={attachmentManager} + /> +
+ ) : null} + + {pieceworkField ? ( +
+
个人计件
+ onPieceworkChange(event.target.value)} + disabled={detail.taskState === 'expired'} + /> +
+ ) : null} + + {remarkField && !hideCurrentRemark ? ( +
+
{remarkField.name}
+