import { Injectable } from '@nestjs/common'; import type { ImportContactsResult } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { projectCardAccessWhere } from '../common/record-scope'; import { normalizePhone } from '../contacts/normalize'; import { excelFieldsFromColumns, formatJalaliDate, parseExcelMapping, parseJalaliDateTime, previewExcel, readMappedRows, stripHtml, writeXlsx, type ExcelPreviewResult, } from '../common/excel'; import { chunkImportMeta, resolveExcelImportChunk, } from '../common/excel-import-session'; import { ProjectBoardsService } from './project-boards.service'; const CASE_COLUMNS = [ { header: '_type', key: '_type', width: 12 }, { header: 'کد دیدار کارت', key: 'didarId', width: 14 }, { header: 'عنوان کارت', key: 'title', width: 28 }, { header: 'توضیحات کارت', key: 'notes', width: 32 }, { header: 'وضعیت کارت', key: 'status', width: 12 }, { header: 'مسئول کارت', key: 'ownerName', width: 16 }, { header: 'مجوز مشاهده کارت', key: 'visibility', width: 18 }, { header: 'تاریخ ثبت کارت', key: 'createdAt', width: 16 }, { header: 'نام خانوادگی شخص کارت', key: 'personName', width: 22 }, { header: 'کد دیدار شخص', key: 'personCode', width: 14 }, { header: 'موبایل شخص کارت', key: 'personMobile', width: 16 }, { header: 'تابلوی پروژه کارت', key: 'boardName', width: 20 }, { header: 'مرحله تابلوی پروژه کارت', key: 'stageName', width: 18 }, { header: 'تاریخ انجام کارت', key: 'doneDate', width: 16 }, { header: 'برچسب', key: 'tag', width: 16 }, ]; const CASE_ALIASES: Record = { _type: '_type', type: '_type', کددیدارکارت: 'didarId', عنوانکارت: 'title', عنوان: 'title', توضیحاتکارت: 'notes', وضعیتکارت: 'status', مسئولکارت: 'ownerName', مجوزمشاهدهکارت: 'visibility', تاریخثبتکارت: 'createdAt', نامخانوادگیشخصکارت: 'personName', کددیدارشخص: 'personCode', موبایلشخصکارت: 'personMobile', تابلویپروژهکارت: 'boardName', مرحلهتابلویپروژهکارت: 'stageName', // سرستون‌های قدیمی (قبل از تغییر نام کاریز → تابلوی پروژه) کاریزکارت: 'boardName', مرحلهکاریزکارت: 'stageName', تاریخانجامکارت: 'doneDate', برچسب: 'tag', }; function isClosedStatus(raw?: string) { return /بسته|انجام|done|closed/i.test(raw ?? ''); } @Injectable() export class ProjectCardsExcelService { constructor( private readonly prisma: PrismaService, private readonly boards: ProjectBoardsService, ) {} async export(actor: Actor): Promise { const rows = await this.prisma.projectCard.findMany({ where: { organizationId: actor.organizationId, ...projectCardAccessWhere(actor) }, include: { person: { select: { id: true, firstName: true, lastName: true, mobile: true }, }, owner: { select: { fullName: true } }, board: { select: { name: true } }, stage: { select: { name: true } }, }, orderBy: [{ createdAt: 'desc' }], take: 10000, }); return writeXlsx( 'Case', CASE_COLUMNS, rows.map((row) => { const closed = /انجام|بسته|done/i.test(row.stage.name); return { _type: 'Case', didarId: row.id, title: row.title, notes: row.notes ?? '', status: closed ? 'بسته' : 'باز', ownerName: row.owner?.fullName ?? '', visibility: 'همه افراد شرکت', createdAt: formatJalaliDate(row.createdAt), personName: row.person ? `${row.person.firstName} ${row.person.lastName}`.trim() : '', personCode: row.person?.id ?? '', personMobile: row.person?.mobile ?? '', boardName: row.board.name, stageName: row.stage.name, doneDate: closed ? formatJalaliDate(row.updatedAt) : '', tag: '', }; }), ); } preview(file: Express.Multer.File): Promise { return previewExcel(file, CASE_ALIASES, excelFieldsFromColumns(CASE_COLUMNS, ['title'])); } async import( actor: Actor, file: Express.Multer.File | undefined, mappingRaw?: string, chunk?: { sessionId?: string; offset?: string | number; limit?: string | number }, ): Promise { const ctx = await resolveExcelImportChunk({ organizationId: actor.organizationId, file, sessionId: chunk?.sessionId, offset: chunk?.offset, limit: chunk?.limit, loadRows: () => readMappedRows(file!, CASE_ALIASES, parseExcelMapping(mappingRaw)), }); const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; const affected = new Map(); for (let i = 0; i < ctx.slice.length; i++) { const row = ctx.slice[i]!; const rowNum = ctx.offset + i + 2; if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'case') { continue; } const title = (row.title || '').trim(); if (title.length < 2) { result.errors.push({ row: rowNum, message: 'عنوان کارت الزامی است' }); continue; } try { const { boardId, stageId, boardName } = await this.resolveBoardAndStage( actor.organizationId, row.boardName, row.stageName, isClosedStatus(row.status), ); affected.set(boardId, boardName); const duplicate = await this.prisma.projectCard.findFirst({ where: { organizationId: actor.organizationId, boardId, title }, select: { id: true }, }); if (duplicate) { result.skipped += 1; continue; } const personId = await this.findPersonId(actor.organizationId, row); const ownerId = await this.findUserId(actor.organizationId, row.ownerName); const notes = [stripHtml(row.notes ?? ''), row.tag, row.didarId ? `کد دیدار: ${row.didarId}` : ''] .map((part) => part.trim()) .filter(Boolean) .join('\n'); const doneAt = parseJalaliDateTime(row.doneDate); const notesWithDone = doneAt ? `${notes}${notes ? '\n' : ''}تاریخ انجام: ${row.doneDate}`.trim() : notes; await this.boards.createCard(actor.organizationId, actor, { title, boardId, stageId, notes: notesWithDone || undefined, personId, ownerId, }); result.created += 1; } catch (e) { result.errors.push({ row: rowNum, message: e instanceof Error ? e.message : 'خطای ناشناخته', }); } } const entries = [...affected.entries()]; if (entries[0]) { result.boardId = entries[0][0]; result.boards = entries.map(([, n]) => n); } return { ...result, ...chunkImportMeta(ctx) }; } private async resolveBoardAndStage( organizationId: string, boardName?: string, stageName?: string, closed = false, ) { const name = boardName?.trim() || 'پروژه داخلی'; const wanted = stageName?.trim(); let board = await this.prisma.projectBoard.findFirst({ where: { organizationId, name: { equals: name, mode: 'insensitive' } }, include: { stages: { orderBy: { position: 'asc' } } }, }); if (!board) { const stageCreates = wanted ? [{ name: wanted, position: 0 }] : [ { name: 'صف', position: 0 }, { name: 'در حال انجام', position: 1 }, { name: 'بازبینی', position: 2 }, { name: 'انجام‌شده', position: 3 }, ]; board = await this.prisma.projectBoard.create({ data: { organizationId, name, isDefault: false, stages: { create: stageCreates }, }, include: { stages: { orderBy: { position: 'asc' } } }, }); } if (wanted) { const existing = board.stages.find( (s) => s.name.trim().toLowerCase() === wanted.toLowerCase(), ); if (existing) return { boardId: board.id, stageId: existing.id, boardName: board.name }; const maxPos = board.stages.reduce((m, s) => Math.max(m, s.position), -1); const stage = await this.prisma.projectBoardStage.create({ data: { boardId: board.id, name: wanted, position: maxPos + 1 }, }); return { boardId: board.id, stageId: stage.id, boardName: board.name }; } const stage = closed ? board.stages[board.stages.length - 1] : board.stages[0]; if (!stage) throw new Error('برد مرحله‌ای ندارد'); return { boardId: board.id, stageId: stage.id, boardName: board.name }; } private async findPersonId(organizationId: string, row: Record) { const mobile = normalizePhone(row.personMobile); if (mobile) { const needle = mobile.replace(/^\+98|^98|^0/, ''); const byMobile = await this.prisma.person.findFirst({ where: { organizationId, mobile: { contains: needle } }, select: { id: true }, }); if (byMobile) return byMobile.id; } const name = (row.personName ?? '').trim(); if (name) { const byName = await this.prisma.person.findFirst({ where: { organizationId, OR: [ { lastName: { contains: name, mode: 'insensitive' } }, { firstName: { contains: name.split(/\s+/)[0]!, mode: 'insensitive' } }, ], }, select: { id: true }, }); if (byName) return byName.id; } return null; } private async findUserId(organizationId: string, name?: string) { const n = name?.trim(); if (!n) return null; const row = await this.prisma.user.findFirst({ where: { fullName: { contains: n, mode: 'insensitive' }, memberships: { some: { organizationId } }, }, select: { id: true }, }); return row?.id ?? null; } }