Compare commits
124
Commits
1ba352f127
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3696713443 | ||
|
|
d282627af7 | ||
|
|
fb6aa77465 | ||
|
|
54d360ded8 | ||
|
|
717a41e94b | ||
|
|
f36fddc23b | ||
|
|
e04aa14b84 | ||
|
|
3c27fae626 | ||
|
|
33eeb3242a | ||
|
|
ae16e43013 | ||
|
|
7a55b84b81 | ||
|
|
a1ac677b3a | ||
|
|
b00465439e | ||
|
|
9af0e9e94e | ||
|
|
b745b6d460 | ||
|
|
fec5ece255 | ||
|
|
f4f2311ae4 | ||
|
|
57e0fdd600 | ||
|
|
b583858088 | ||
|
|
453ff1a5f6 | ||
|
|
68fcece743 | ||
|
|
de77066cb0 | ||
|
|
d23dd20237 | ||
|
|
8a4bd70ad4 | ||
|
|
d87321c7bf | ||
|
|
1a05eee46d | ||
|
|
ec9a7cf9c9 | ||
|
|
4f08ffae36 | ||
|
|
d7cb92bf81 | ||
|
|
871d7bba1a | ||
|
|
f13a1249af | ||
|
|
fdf1fbe4aa | ||
|
|
6df198321a | ||
|
|
4c8604d346 | ||
|
|
25a24772fc | ||
|
|
932dc3f1c1 | ||
|
|
5b26b9156d | ||
|
|
d12d7b91cb | ||
|
|
49459707cb | ||
|
|
ba7f942059 | ||
|
|
96105468d4 | ||
|
|
7a8007fff0 | ||
|
|
da851bd9e5 | ||
|
|
dae3ae3f7e | ||
|
|
b03d97dd9e | ||
|
|
8b8d01976d | ||
|
|
4764562bd4 | ||
|
|
0e1bc4a1fe | ||
|
|
3c9b906bd6 | ||
|
|
ef58922e13 | ||
|
|
06036b00d3 | ||
|
|
d33d88e76d | ||
|
|
5ef693990c | ||
|
|
6ebd45f2cb | ||
|
|
33f33f9521 | ||
|
|
97f00d7e6d | ||
|
|
b8c49293f7 | ||
|
|
f36711174e | ||
|
|
abb1a3a18e | ||
|
|
21d2c69227 | ||
|
|
0ca9540524 | ||
|
|
dc691c5192 | ||
|
|
1ef7f91771 | ||
|
|
94f0383c79 | ||
|
|
c976c0bcd6 | ||
|
|
57a0b379f6 | ||
|
|
7f6d9a84d6 | ||
|
|
6abbf8e8fd | ||
|
|
39cb5c2548 | ||
|
|
004d1dff93 | ||
|
|
78a7d6a68a | ||
|
|
5b18749005 | ||
|
|
08a278e1c8 | ||
|
|
d71c2dbef4 | ||
|
|
1ccbefa7a3 | ||
|
|
14f4e9d7d8 | ||
|
|
52edccf74e | ||
|
|
bffe174162 | ||
|
|
35d74d33ed | ||
|
|
c337b0b4c7 | ||
|
|
c26353624e | ||
|
|
534dae45b2 | ||
|
|
79733e6c96 | ||
|
|
fdabdc54a3 | ||
|
|
1b10ee30df | ||
|
|
c273dd6c58 | ||
|
|
acb5d51f24 | ||
|
|
06977d27ed | ||
|
|
43437b0280 | ||
|
|
28938e8f77 | ||
|
|
e6e90aabf3 | ||
|
|
a0936bf19f | ||
|
|
18a7cd3b5a | ||
|
|
4af248b06a | ||
|
|
d5dd13863a | ||
|
|
81edf61065 | ||
|
|
8e1e94a439 | ||
|
|
590f8114cc | ||
|
|
8d01bce0c2 | ||
|
|
8b2777be4e | ||
|
|
d7b5085096 | ||
|
|
cbc2e4ee8a | ||
|
|
a17c7dbabc | ||
|
|
99d686fefb | ||
|
|
b9bb2ff9fa | ||
|
|
0d5e1034e5 | ||
|
|
6f42440b3b | ||
|
|
63a1803adc | ||
|
|
2cc2d358e4 | ||
|
|
463a1652a6 | ||
|
|
f15c5b9141 | ||
|
|
1d3588a69d | ||
|
|
9812e4e5ba | ||
|
|
8ae6a928f4 | ||
|
|
2ff35e55fc | ||
|
|
15f89aec04 | ||
|
|
bebd1be29b | ||
|
|
0daaef0b39 | ||
|
|
91637cb3a6 | ||
|
|
546a7a7e46 | ||
|
|
48815852fc | ||
|
|
f8128ebe3a | ||
|
|
6ec7a1a942 | ||
|
|
2d690b4c04 |
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
node_modules
|
||||
**/dist
|
||||
**/.env
|
||||
**/.env.*
|
||||
apps/stt/.venv
|
||||
uploads
|
||||
docs
|
||||
e2e
|
||||
test-results
|
||||
playwright-report
|
||||
graphify-out
|
||||
.worktrees
|
||||
*.md
|
||||
crm-node_modules.tgz
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Start time
|
||||
run: echo "BUILD_STARTED=$(date +%s)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Free disk space
|
||||
id: prune
|
||||
run: |
|
||||
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
|
||||
docker builder prune -af || true
|
||||
docker image prune -af || true
|
||||
docker system prune -af --volumes || true
|
||||
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
|
||||
|
||||
- name: Checkout code
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve image tag
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
else
|
||||
TAG="$(git rev-parse --short HEAD)"
|
||||
fi
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "registry=dock.monsys.ir" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker images
|
||||
id: build
|
||||
run: |
|
||||
set -eu
|
||||
tag="${{ steps.meta.outputs.tag }}"
|
||||
reg="${{ steps.meta.outputs.registry }}"
|
||||
docker build -f Dockerfile.api -t "$reg/montact-api:$tag" -t "$reg/montact-api:latest" .
|
||||
docker build -f Dockerfile.web -t "$reg/montact-web:$tag" -t "$reg/montact-web:latest" .
|
||||
docker build -f Dockerfile.stt -t "$reg/montact-stt:$tag" -t "$reg/montact-stt:latest" .
|
||||
|
||||
# Registry login is unsolved. These two steps stay off so a tag run
|
||||
# only proves the three images build. Remove `if: false` on both when
|
||||
# dock.monsys.ir accepts the runner.
|
||||
- name: Login to registry
|
||||
id: login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
USER_NAME="${REGISTRY_USERNAME:-admin}"
|
||||
printf '%s\n' "$REGISTRY_PASSWORD" | docker login "dock.monsys.ir" -u "$USER_NAME" --password-stdin
|
||||
|
||||
- name: Push Docker images
|
||||
id: push
|
||||
run: |
|
||||
set -eu
|
||||
tag="${{ steps.meta.outputs.tag }}"
|
||||
reg="${{ steps.meta.outputs.registry }}"
|
||||
for name in montact-api montact-web montact-stt; do
|
||||
docker push "$reg/$name:$tag"
|
||||
docker push "$reg/$name:latest"
|
||||
done
|
||||
|
||||
- name: Notify Bale
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
BALE_TOKEN: ${{ secrets.BALE_BOT_TOKEN || vars.BALE_BOT_TOKEN }}
|
||||
BALE_CHAT_ID: ${{ vars.BALE_CHAT_ID }}
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
REG: ${{ steps.meta.outputs.registry }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
|
||||
STEP_OUTCOMES: |
|
||||
Checkout code=${{ steps.checkout.outcome }}
|
||||
Resolve image tag=${{ steps.meta.outcome }}
|
||||
Build Docker images=${{ steps.build.outcome }}
|
||||
Login to registry=${{ steps.login.outcome }}
|
||||
Push Docker images=${{ steps.push.outcome }}
|
||||
run: |
|
||||
if [ -z "$BALE_TOKEN" ] || [ -z "$BALE_CHAT_ID" ]; then
|
||||
echo "BALE_BOT_TOKEN / BALE_CHAT_ID not set in Gitea — skipping notification"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
failed_step=$(printf '%s\n' "$STEP_OUTCOMES" | grep -E '=(failure|cancelled)$' | head -n1 | cut -d= -f1)
|
||||
if [ -n "$failed_step" ]; then
|
||||
title="❌ بیلد ناموفق"
|
||||
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Push Docker images=success$'; then
|
||||
title="✅ بیلد موفق"
|
||||
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Build Docker images=success$'; then
|
||||
title="✅ بیلد موفق؛ پوش رجیستری هنوز خاموش است"
|
||||
else
|
||||
title="⚠️ بیلد کامل نشد"
|
||||
fi
|
||||
|
||||
commit=$(git log -1 --pretty='%h — %s' 2>/dev/null || echo "${GITHUB_SHA:0:7}")
|
||||
elapsed=$(( $(date +%s) - ${BUILD_STARTED:-$(date +%s)} ))
|
||||
|
||||
text="${title}
|
||||
پروژه: ${GITHUB_REPOSITORY}
|
||||
تگ: ${TAG:-${GITHUB_REF_NAME}}"
|
||||
if [ -z "$failed_step" ] && [ -n "$TAG" ]; then
|
||||
text="${text}
|
||||
image: ${REG}/montact-api:${TAG}
|
||||
image: ${REG}/montact-web:${TAG}
|
||||
image: ${REG}/montact-stt:${TAG}"
|
||||
fi
|
||||
[ -n "$failed_step" ] && text="${text}
|
||||
مرحلهٔ خطا: ${failed_step}"
|
||||
text="${text}
|
||||
کامیت: ${commit}
|
||||
توسط: ${GITHUB_ACTOR}
|
||||
مدت: $((elapsed / 60))m$((elapsed % 60))s
|
||||
لاگ: ${RUN_URL}"
|
||||
msg_file=$(mktemp)
|
||||
printf '%s\n' "$text" | sed 's/^[[:space:]]*//' > "$msg_file"
|
||||
|
||||
curl -sS -m 20 -X POST "https://tapi.bale.ai/bot${BALE_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${BALE_CHAT_ID}" \
|
||||
--data-urlencode "text@${msg_file}"
|
||||
@@ -0,0 +1,29 @@
|
||||
# NestJS API + Prisma. Build context is the monorepo root.
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm ci
|
||||
COPY apps/api apps/api
|
||||
COPY packages/shared packages/shared
|
||||
RUN npm run build -w @mento/shared \
|
||||
&& npm run prisma:generate -w @mento/api \
|
||||
&& npm run build -w @mento/api
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/shared ./packages/shared
|
||||
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
|
||||
WORKDIR /app/apps/api
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/src/main.js"]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Local Faster-Whisper. Not published on Ingress.
|
||||
FROM python:3.11-slim-bookworm
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY apps/stt/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY apps/stt/main.py ./main.py
|
||||
ENV STT_MODEL=small \
|
||||
STT_DEVICE=cpu \
|
||||
STT_COMPUTE_TYPE=int8 \
|
||||
HF_HOME=/models
|
||||
EXPOSE 3040
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm ci
|
||||
COPY apps/web apps/web
|
||||
COPY packages/shared packages/shared
|
||||
RUN npm run build -w @mento/shared && npm run build -w @mento/web
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -12,7 +12,7 @@
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\"",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts"
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts test/person-name.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mento/shared": "*",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE "CustomerGroup" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "CustomerGroup_parentId_idx" ON "CustomerGroup"("parentId");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "CustomerGroup"
|
||||
ADD CONSTRAINT "CustomerGroup_parentId_fkey"
|
||||
FOREIGN KEY ("parentId") REFERENCES "CustomerGroup"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsad" DOUBLE PRECISION;
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsadTalayee" DOUBLE PRECISION;
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "maliatDarsad" DOUBLE PRECISION;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ActivityImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"activityId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"uploadedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- پیامک پایان اشتراک فقط برای انقضاهای بعدی؛ حسابهای قبلاً تمامشده دوباره خبر نمیدهند
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3);
|
||||
|
||||
UPDATE "BillingAccount"
|
||||
SET "expiryNotifiedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "planCode" = 'lifetime'
|
||||
OR "status" = 'expired'
|
||||
OR "expiresAt" < CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "ActivityBoardMember" (
|
||||
"boardId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ActivityBoardMember_pkey" PRIMARY KEY ("boardId","userId")
|
||||
);
|
||||
|
||||
CREATE INDEX "ActivityBoardMember_userId_idx" ON "ActivityBoardMember"("userId");
|
||||
|
||||
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,22 @@
|
||||
ALTER TABLE "Person" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "Person" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- نامهایی که از تهحساب با عنوان آمدهاند؛ خود متن نام دست نمیخورد
|
||||
UPDATE "Person"
|
||||
SET "gender" = 'male', "showGenderInName" = true
|
||||
WHERE regexp_replace(
|
||||
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||
|
||||
UPDATE "Person"
|
||||
SET "gender" = 'female', "showGenderInName" = true
|
||||
WHERE "gender" = 'none'
|
||||
AND regexp_replace(
|
||||
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE "Lead" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "Lead" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- عنوان داخل نام مخاطب، و اگر مخاطب خالی است داخل عنوان لید
|
||||
UPDATE "Lead"
|
||||
SET "gender" = 'male', "showGenderInName" = true
|
||||
WHERE regexp_replace(
|
||||
regexp_replace(
|
||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||
'ي',
|
||||
'ی',
|
||||
'g'
|
||||
),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||
|
||||
UPDATE "Lead"
|
||||
SET "gender" = 'female', "showGenderInName" = true
|
||||
WHERE "gender" = 'none'
|
||||
AND regexp_replace(
|
||||
regexp_replace(
|
||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||
'ي',
|
||||
'ی',
|
||||
'g'
|
||||
),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TYPE "CustomFieldEntity" ADD VALUE IF NOT EXISTS 'note';
|
||||
|
||||
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "customFields" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "NoteCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"tone" TEXT NOT NULL DEFAULT 'general',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showInBar" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isSystem" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NoteCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "NoteCategory_organizationId_key_key" ON "NoteCategory"("organizationId", "key");
|
||||
CREATE INDEX "NoteCategory_organizationId_position_idx" ON "NoteCategory"("organizationId", "position");
|
||||
|
||||
ALTER TABLE "NoteCategory" ADD CONSTRAINT "NoteCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "Organization" ADD COLUMN "fileBytes" BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "Organization" ADD COLUMN "chatFileBytes" BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "Organization" ADD COLUMN "fileBytesSyncedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "storageQuotaBytes" BIGINT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "maxUsers" INTEGER;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "PlatformMasterPassword" (
|
||||
"id" TEXT NOT NULL DEFAULT 'default',
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- دموی ۷ روزه که هنوز سقف کاربر ندارد، پیشفرض ۳ کاربر میگیرد
|
||||
UPDATE "BillingAccount"
|
||||
SET "maxUsers" = 3
|
||||
WHERE "planCode" = 'trial' AND "maxUsers" IS NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
|
||||
|
||||
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
|
||||
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
|
||||
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE "DataFix" (
|
||||
"key" TEXT NOT NULL,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "AccountingConnection"
|
||||
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "customerSyncLastSlot" TEXT,
|
||||
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
|
||||
ADD COLUMN "customerSyncLastSummary" TEXT,
|
||||
ADD COLUMN "customerSyncLastError" TEXT;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'generic';
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenCiphertext" TEXT;
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenIv" TEXT;
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenTag" TEXT;
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "fromNumber" TEXT;
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "callerExtension" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipConnection_organizationId_provider_idx" ON "VoipConnection"("organizationId", "provider");
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "lineExtension" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipCallLog_organizationId_lineExtension_idx" ON "VoipCallLog"("organizationId", "lineExtension");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipCallLog_organizationId_status_idx" ON "VoipCallLog"("organizationId", "status");
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
|
||||
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- مسئولان اختیاری روی گروههای مشتری
|
||||
CREATE TABLE IF NOT EXISTS "_CustomerGroupAssignees" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "_CustomerGroupAssignees_AB_unique" ON "_CustomerGroupAssignees"("A", "B");
|
||||
CREATE INDEX IF NOT EXISTS "_CustomerGroupAssignees_B_index" ON "_CustomerGroupAssignees"("B");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_A_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "_CustomerGroupAssignees"
|
||||
ADD CONSTRAINT "_CustomerGroupAssignees_A_fkey"
|
||||
FOREIGN KEY ("A") REFERENCES "CustomerGroup"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_B_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "_CustomerGroupAssignees"
|
||||
ADD CONSTRAINT "_CustomerGroupAssignees_B_fkey"
|
||||
FOREIGN KEY ("B") REFERENCES "User"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "smsTemplates" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -33,6 +33,17 @@ enum ActivityStatus {
|
||||
canceled
|
||||
}
|
||||
|
||||
enum ActivityCompletionMode {
|
||||
any
|
||||
all
|
||||
}
|
||||
|
||||
/// چه کسانی میتوانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
|
||||
enum ActivityEditPolicy {
|
||||
creator
|
||||
assignees
|
||||
}
|
||||
|
||||
enum ActivityPriority {
|
||||
low
|
||||
medium
|
||||
@@ -52,6 +63,7 @@ enum CustomFieldEntity {
|
||||
person
|
||||
company
|
||||
deal
|
||||
note
|
||||
}
|
||||
|
||||
enum CustomFieldType {
|
||||
@@ -150,9 +162,19 @@ model Organization {
|
||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||
/// ماژولهای فعال منو برای این محیط
|
||||
enabledModules String[] @default([])
|
||||
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
|
||||
activityPriorities Json @default("{}")
|
||||
/// قالبهای آمادهٔ پیامک دستی ([{ id, name, body }, …])
|
||||
smsTemplates Json @default("[]")
|
||||
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
||||
priorArchiveEnabledAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
/// حجم فایلهای محلی این محیط، به بایت. متن رکوردها (مشتری، فعالیت، فاکتور) داخلش نیست.
|
||||
fileBytes BigInt @default(0)
|
||||
/// حجم فایلهای گفتگو که از این محیط آپلود شدهاند
|
||||
chatFileBytes BigInt @default(0)
|
||||
/// آخرین باری که fileBytes از روی فایلهای واقعی محاسبه شده
|
||||
fileBytesSyncedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
@@ -169,11 +191,13 @@ model Organization {
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
notes Note[]
|
||||
noteCategories NoteCategory[]
|
||||
noteRevisions NoteRevision[]
|
||||
customFields CustomFieldDefinition[]
|
||||
products Product[]
|
||||
notifications Notification[]
|
||||
noteAttachments NoteAttachment[]
|
||||
activityImages ActivityImage[]
|
||||
leadForms LeadForm[]
|
||||
quotes Quote[]
|
||||
invoices Invoice[]
|
||||
@@ -286,6 +310,8 @@ model User {
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
ownedCompanies Company[] @relation("CompanyOwner")
|
||||
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
|
||||
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||
activities Activity[] @relation("ActivityAssignee")
|
||||
assignedActivities Activity[] @relation("ActivityAssignees")
|
||||
@@ -293,6 +319,7 @@ model User {
|
||||
notes Note[] @relation("NoteAuthor")
|
||||
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
|
||||
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
||||
activityImages ActivityImage[] @relation("ActivityImageUploader")
|
||||
notifications Notification[]
|
||||
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
||||
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
||||
@@ -309,6 +336,7 @@ model User {
|
||||
tablePreferences UserTablePreference[]
|
||||
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
||||
teamMemberships TeamMember[]
|
||||
activityBoardMembers ActivityBoardMember[]
|
||||
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
|
||||
assignedLeads Lead[] @relation("LeadAssignee")
|
||||
createdLeads Lead[] @relation("LeadCreator")
|
||||
@@ -342,6 +370,10 @@ model OrgRole {
|
||||
permissions String[] @default([])
|
||||
/// عمق داده بهازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
|
||||
dataAccessPolicy Json @default("{}")
|
||||
/// حسابهای مالی اضافی این نقش؛ همراه با حسابهایی که خودش مسئول است
|
||||
treasuryAccountIds String[] @default([])
|
||||
/// اعضای نقش، حسابهایی را ببینند که نام خودشان مسئول آن است
|
||||
treasuryOwnAccounts Boolean @default(false)
|
||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||
systemKey String?
|
||||
/// نقش والد برای سلسلهمراتب زیرنقشها
|
||||
@@ -376,6 +408,8 @@ model Membership {
|
||||
dataVisibilityScopes Json @default("{}")
|
||||
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
|
||||
dataAccessOverride Json @default("{}")
|
||||
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخشها و حسابهای مالی همین کاربر
|
||||
treasuryAccessOverride Json?
|
||||
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||
priorArchiveEnabled Boolean @default(false)
|
||||
/// current | archive
|
||||
@@ -440,6 +474,13 @@ model PlatformAdmin {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// رمز کلی سوپرادمین: با موبایل هر کاربر و این رمز میشود وارد حساب CRM او شد
|
||||
model PlatformMasterPassword {
|
||||
id String @id @default("default")
|
||||
passwordHash String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
|
||||
model BillingAccount {
|
||||
id String @id @default(cuid())
|
||||
@@ -452,9 +493,15 @@ model BillingAccount {
|
||||
startsAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
maxOrganizations Int @default(1)
|
||||
/// سقف حجم فایل همهٔ محیطهای این مالک، به بایت. null = بدون سقف
|
||||
storageQuotaBytes BigInt?
|
||||
/// سقف کاربر فعال همهٔ محیطهای این مالک. null = هنوز تعیین نشده
|
||||
maxUsers Int?
|
||||
/// رزرو برای درگاه/لندینگ بعدی
|
||||
externalPaymentRef String?
|
||||
notes String?
|
||||
/// پیامک پایان اشتراک برای مدیران پنل؛ خالی یعنی هنوز اطلاع داده نشده
|
||||
expiryNotifiedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -494,6 +541,7 @@ model Company {
|
||||
customFields Json @default("{}")
|
||||
groupLinks CompanyGroupLink[]
|
||||
people Person[]
|
||||
financialDocuments FinancialDocument[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
@@ -527,14 +575,21 @@ model CustomerGroup {
|
||||
/// گروه متناظر در تهحساب (GID) روی همان اتصال
|
||||
tahesabConnectionId String?
|
||||
tahesabGid String?
|
||||
/// گروه اصلی؛ فقط یک سطح (اصلی / فرعی)
|
||||
parentId String?
|
||||
parent CustomerGroup? @relation("CustomerGroupTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children CustomerGroup[] @relation("CustomerGroupTree")
|
||||
people PersonGroupLink[]
|
||||
companies CompanyGroupLink[]
|
||||
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود میبینند
|
||||
assignees User[] @relation("CustomerGroupAssignees")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, name])
|
||||
@@index([organizationId, position])
|
||||
@@index([tahesabConnectionId, tahesabGid])
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model PersonGroupLink {
|
||||
@@ -569,6 +624,10 @@ model Person {
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
firstName String
|
||||
lastName String
|
||||
/// male | female | none
|
||||
gender String @default("none")
|
||||
/// اگر روشن باشد، مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره میشود
|
||||
showGenderInName Boolean @default(false)
|
||||
/// نام قبل از همگامسازی مبدأ (مثلاً تهحساب)
|
||||
previousFirstName String?
|
||||
previousLastName String?
|
||||
@@ -757,6 +816,7 @@ model ActivityBoard {
|
||||
position Int @default(0)
|
||||
stages ActivityStage[]
|
||||
activities Activity[]
|
||||
members ActivityBoardMember[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -764,6 +824,17 @@ model ActivityBoard {
|
||||
@@index([organizationId, position])
|
||||
}
|
||||
|
||||
/// عضو گروه فعالیت؛ اگر گروهی هیچ عضوی نداشته باشد برای همهٔ محیط باز است
|
||||
model ActivityBoardMember {
|
||||
boardId String
|
||||
board ActivityBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([boardId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ActivityStage {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
@@ -830,6 +901,12 @@ model Activity {
|
||||
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک میشود
|
||||
reminderSentAt DateTime?
|
||||
completedAt DateTime?
|
||||
/// any = با انجام یکی بسته میشود؛ all = هر مسئول باید سهم خودش را بزند
|
||||
completionMode ActivityCompletionMode @default(any)
|
||||
/// مسئولانی که سهم خود را در حالت all ثبت کردهاند
|
||||
completedByIds String[] @default([])
|
||||
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
|
||||
editPolicy ActivityEditPolicy @default(creator)
|
||||
tags String[] @default([])
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
@@ -851,6 +928,8 @@ model Activity {
|
||||
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
|
||||
stageId String?
|
||||
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
|
||||
/// ترتیب کارت داخل همان ستون کانبان
|
||||
boardPosition Int @default(0)
|
||||
/// نوع سفارشی؛ null → نوع اصلیِ همان type
|
||||
typeDefId String?
|
||||
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
|
||||
@@ -861,10 +940,12 @@ model Activity {
|
||||
/// لید مرتبط (مثلاً فعالیت ساختهشده از ارسال فرم)
|
||||
leadId String?
|
||||
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||
images ActivityImage[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stageId])
|
||||
@@index([stageId, boardPosition])
|
||||
@@index([boardId])
|
||||
@@index([typeDefId])
|
||||
@@index([leadId])
|
||||
@@ -879,6 +960,26 @@ model Activity {
|
||||
@@index([dealId])
|
||||
}
|
||||
|
||||
/// عکس فشردهشدهٔ یادداشت فعالیت (حداکثر ۲۰۰ کیلوبایت)
|
||||
model ActivityImage {
|
||||
id String @id
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
activityId String
|
||||
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
|
||||
fileName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
storageKey String
|
||||
uploadedById String?
|
||||
uploadedBy User? @relation("ActivityImageUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([activityId, createdAt])
|
||||
@@index([organizationId])
|
||||
@@index([uploadedById])
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
@@ -894,6 +995,8 @@ model Note {
|
||||
checklist Json?
|
||||
locationText String?
|
||||
analyzeEnabled Boolean @default(true)
|
||||
/// مقادیر فیلدهای سفارشی یادداشت
|
||||
customFields Json @default("{}")
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
@@ -923,6 +1026,26 @@ model Note {
|
||||
@@index([authorId])
|
||||
}
|
||||
|
||||
/// دستههای نوار فیلتر یادداشت؛ برای هر سازمان قابل تغییر است
|
||||
model NoteCategory {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
key String
|
||||
label String
|
||||
tone String @default("general")
|
||||
position Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
/// در نوار «همه / معمولی / پیگیری / …» دیده شود
|
||||
showInBar Boolean @default(true)
|
||||
isSystem Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, key])
|
||||
@@index([organizationId, position])
|
||||
}
|
||||
|
||||
/// اسنپشات متن قبل از هر ویرایش معنادار یادداشت
|
||||
model NoteRevision {
|
||||
id String @id @default(cuid())
|
||||
@@ -1744,6 +1867,13 @@ model AccountingConnection {
|
||||
lastHealthAt DateTime?
|
||||
lastHealthOk Boolean?
|
||||
lastError String?
|
||||
/// ساعتهای بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
|
||||
customerSyncHours Int[] @default([])
|
||||
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
|
||||
customerSyncLastSlot String?
|
||||
customerSyncLastRunAt DateTime?
|
||||
customerSyncLastSummary String?
|
||||
customerSyncLastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
personLinks PersonAccountingLink[]
|
||||
@@ -1879,6 +2009,10 @@ model AccountingAsnadLine {
|
||||
mali Float?
|
||||
count Float?
|
||||
ayar Float?
|
||||
/// درصد اجرت سند تهحساب؛ بعد از همگامسازی پر میشود
|
||||
darsad Float?
|
||||
darsadTalayee Float?
|
||||
maliatDarsad Float?
|
||||
mazaneh Float?
|
||||
fi Float?
|
||||
sharh1 String?
|
||||
@@ -2229,6 +2363,12 @@ model FinancialDocument {
|
||||
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
userId String?
|
||||
partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull)
|
||||
/// person | company | user | other
|
||||
partyKind String?
|
||||
partyName String?
|
||||
accountId String?
|
||||
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
|
||||
@@ -2249,6 +2389,8 @@ model FinancialDocument {
|
||||
@@index([organizationId, kind, bookedAt])
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
|
||||
@@ -2329,8 +2471,11 @@ model FinancialPayroll {
|
||||
period String
|
||||
baseAmount Decimal @db.Decimal(18, 2)
|
||||
benefits Decimal @default(0) @db.Decimal(18, 2)
|
||||
benefitsReason String?
|
||||
bonus Decimal @default(0) @db.Decimal(18, 2)
|
||||
bonusReason String?
|
||||
deductions Decimal @default(0) @db.Decimal(18, 2)
|
||||
deductionsReason String?
|
||||
payable Decimal @db.Decimal(18, 2)
|
||||
dueDate DateTime
|
||||
notes String?
|
||||
@@ -2835,18 +2980,28 @@ model MontifyWebhookDelivery {
|
||||
@@index([connectionId, createdAt])
|
||||
}
|
||||
|
||||
/// اتصال تلفن سازمانی / سانترال (وبهوک عمومی)
|
||||
/// اتصال تلفن سازمانی / سانترال (وبهوک عمومی یا ارائهدهنده)
|
||||
model VoipConnection {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
/// generic | daftareshoma
|
||||
provider String @default("generic")
|
||||
enabled Boolean @default(true)
|
||||
webhookSecretCiphertext String?
|
||||
webhookSecretIv String?
|
||||
webhookSecretTag String?
|
||||
/// قالب URL شروع تماس؛ جای {number} شماره مینشیند
|
||||
/// قالب URL شروع تماس؛ جای {number} شماره مینشیند (generic)
|
||||
clickToCallUrl String?
|
||||
/// توکن Bearer API دفتر شما (رمزنگاریشده)
|
||||
apiTokenCiphertext String?
|
||||
apiTokenIv String?
|
||||
apiTokenTag String?
|
||||
/// شماره مبدأ در تماس خروجی دفتر شما (from_number)
|
||||
fromNumber String?
|
||||
/// خط/داخلی سازمان در دفتر شما (caller_extension)
|
||||
callerExtension String?
|
||||
lastEventAt DateTime?
|
||||
lastError String?
|
||||
calls VoipCallLog[]
|
||||
@@ -2854,6 +3009,7 @@ model VoipConnection {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, enabled])
|
||||
@@index([organizationId, provider])
|
||||
}
|
||||
|
||||
model VoipCallLog {
|
||||
@@ -2868,6 +3024,8 @@ model VoipCallLog {
|
||||
status String @default("completed")
|
||||
fromNumber String?
|
||||
toNumber String?
|
||||
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
|
||||
lineExtension String?
|
||||
personId String?
|
||||
activityId String?
|
||||
durationSec Int?
|
||||
@@ -2892,6 +3050,8 @@ model VoipCallLog {
|
||||
|
||||
@@unique([connectionId, externalCallId])
|
||||
@@index([organizationId, syncedAt])
|
||||
@@index([organizationId, lineExtension])
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
@@index([activityId])
|
||||
@@index([summaryStatus, summaryNextAttemptAt])
|
||||
@@ -2972,6 +3132,10 @@ model Lead {
|
||||
title String
|
||||
/// نام مالک یا مسئول
|
||||
contactName String?
|
||||
/// male | female | none
|
||||
gender String @default("none")
|
||||
/// عنوان «جناب آقای» / «سرکار خانم» داخل نام مخاطب
|
||||
showGenderInName Boolean @default(false)
|
||||
mobile String
|
||||
phone String?
|
||||
city String?
|
||||
@@ -3664,3 +3828,9 @@ model AttendanceEvent {
|
||||
@@index([organizationId, userId, at])
|
||||
@@unique([organizationId, userId, kind, sessionStartedAt])
|
||||
}
|
||||
|
||||
/// علامت یکبار اجرای اصلاح داده
|
||||
model DataFix {
|
||||
key String @id
|
||||
doneAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatClock,
|
||||
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
|
||||
return id;
|
||||
};
|
||||
const resolvePerson = async (row: Record<string, string>) => {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const mobile = storedPhoneNumber(row.personMobile);
|
||||
const name = (row.personName ?? '').trim().toLowerCase();
|
||||
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
||||
if (!key) return null;
|
||||
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
ReorderActivityTypesDto,
|
||||
UpdateActivityBoardDto,
|
||||
UpdateActivityDto,
|
||||
UpdateActivityPrioritiesDto,
|
||||
UpdateActivityStageDto,
|
||||
UpdateActivityTypeDto,
|
||||
} from './dto';
|
||||
@@ -72,14 +74,14 @@ export class ActivitiesController {
|
||||
|
||||
@Get('boards')
|
||||
listBoards(@CurrentUser() user: Actor) {
|
||||
return this.stagesService.listBoards(user.organizationId);
|
||||
return this.stagesService.listBoards(user.organizationId, user);
|
||||
}
|
||||
|
||||
@Post('boards')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
|
||||
return this.stagesService.createBoard(user.organizationId, dto.name);
|
||||
return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
|
||||
}
|
||||
|
||||
@Patch('boards/:id')
|
||||
@@ -103,7 +105,7 @@ export class ActivitiesController {
|
||||
|
||||
@Get('stages')
|
||||
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
||||
return this.stagesService.list(user.organizationId, query.boardId);
|
||||
return this.stagesService.list(user.organizationId, query.boardId, user);
|
||||
}
|
||||
|
||||
@Post('stages')
|
||||
@@ -139,6 +141,18 @@ export class ActivitiesController {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('priorities')
|
||||
listPriorities(@CurrentUser() user: Actor) {
|
||||
return this.typesService.listPriorities(user.organizationId);
|
||||
}
|
||||
|
||||
@Put('priorities')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updatePriorities(@CurrentUser() user: Actor, @Body() dto: UpdateActivityPrioritiesDto) {
|
||||
return this.typesService.updatePriorities(user.organizationId, dto.items);
|
||||
}
|
||||
|
||||
@Get('types')
|
||||
listTypes(@CurrentUser() user: Actor) {
|
||||
return this.typesService.list(user.organizationId);
|
||||
@@ -193,6 +207,10 @@ export class ActivitiesController {
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
priority: query.priority,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
);
|
||||
@@ -226,6 +244,44 @@ export class ActivitiesController {
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
priority: query.priority,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** ترتیب همان لیست میز، برای فعالیت قبلی/بعدی در جزئیات */
|
||||
@Get('nav-ids')
|
||||
navIds(@CurrentUser() user: Actor, @Query() query: ListActivitiesQueryDto) {
|
||||
return this.activitiesService.orderedIds(
|
||||
user.organizationId,
|
||||
user,
|
||||
query.filter ?? 'today',
|
||||
query.q,
|
||||
{
|
||||
personId: query.personId,
|
||||
companyId: query.companyId,
|
||||
dealId: query.dealId,
|
||||
leadId: query.leadId,
|
||||
},
|
||||
{
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
},
|
||||
query.desk,
|
||||
{
|
||||
type: query.type,
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
priority: query.priority,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
@@ -309,6 +365,56 @@ export class ActivitiesController {
|
||||
return this.activitiesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post(':id/images')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
addImage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.activitiesService.addImage(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
file,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/images/:imageId')
|
||||
async downloadImage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Param('imageId') imageId: string,
|
||||
): Promise<StreamableFile> {
|
||||
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
|
||||
user.organizationId,
|
||||
id,
|
||||
imageId,
|
||||
user,
|
||||
);
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
return new StreamableFile(stream, {
|
||||
type: mimeType,
|
||||
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id/images/:imageId')
|
||||
async removeImage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Param('imageId') imageId: string,
|
||||
) {
|
||||
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post(':id/promote-from-prior')
|
||||
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.promoteFromPrior(user, id);
|
||||
@@ -339,12 +445,26 @@ export class ActivitiesController {
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveActivityStageDto,
|
||||
) {
|
||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
||||
return this.activitiesService.moveToStage(
|
||||
user.organizationId,
|
||||
id,
|
||||
user,
|
||||
dto.stageId,
|
||||
dto.position,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user);
|
||||
complete(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Query('force') force?: string,
|
||||
@Query('undo') undo?: string,
|
||||
) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user, {
|
||||
force: force === '1' || force === 'true',
|
||||
undo: undo === '1' || undo === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -7,9 +7,10 @@ import { ActivityTypesService } from './activity-types.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [
|
||||
ActivitiesService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ActivityBoardDto,
|
||||
ActivityStageColor,
|
||||
@@ -34,8 +39,23 @@ type BoardRow = {
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
position: number;
|
||||
members: Array<{ userId: string }>;
|
||||
};
|
||||
|
||||
function toBoardDto(row: BoardRow): ActivityBoardDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: row.isDefault,
|
||||
position: row.position,
|
||||
memberIds: row.members.map((member) => member.userId),
|
||||
};
|
||||
}
|
||||
|
||||
function managesAllBoards(role: string) {
|
||||
return role === 'owner' || role === 'admin';
|
||||
}
|
||||
|
||||
const STAGE_SELECT = {
|
||||
id: true,
|
||||
boardId: true,
|
||||
@@ -50,6 +70,7 @@ const BOARD_SELECT = {
|
||||
name: true,
|
||||
isDefault: true,
|
||||
position: true,
|
||||
members: { select: { userId: true } },
|
||||
} satisfies Prisma.ActivityBoardSelect;
|
||||
|
||||
export type StageDefaults = Record<ActivityStatus, string | null>;
|
||||
@@ -109,14 +130,36 @@ export class ActivityStagesService {
|
||||
});
|
||||
}
|
||||
|
||||
async listBoards(organizationId: string): Promise<ActivityBoardDto[]> {
|
||||
async listBoards(
|
||||
organizationId: string,
|
||||
actor?: { userId: string; role: string },
|
||||
): Promise<ActivityBoardDto[]> {
|
||||
await this.ensureDefaultBoard(organizationId);
|
||||
const rows = await this.prisma.activityBoard.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
return rows.map((b) => ({ ...b }));
|
||||
const boards = rows.map(toBoardDto);
|
||||
if (!actor || managesAllBoards(actor.role)) return boards;
|
||||
return boards.filter(
|
||||
(board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId),
|
||||
);
|
||||
}
|
||||
|
||||
/** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروهها را میبینند */
|
||||
async assertCanAccessBoard(
|
||||
organizationId: string,
|
||||
boardId: string,
|
||||
actor: { userId: string; role: string },
|
||||
) {
|
||||
if (managesAllBoards(actor.role)) return;
|
||||
const members = await this.prisma.activityBoardMember.findMany({
|
||||
where: { boardId, board: { organizationId } },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!members.length || members.some((member) => member.userId === actor.userId)) return;
|
||||
throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید');
|
||||
}
|
||||
|
||||
/** شناسهٔ گروه معتبر؛ خالی = گروه پیشفرض */
|
||||
@@ -131,7 +174,11 @@ export class ActivityStagesService {
|
||||
return board.id;
|
||||
}
|
||||
|
||||
async createBoard(organizationId: string, name: string): Promise<ActivityBoardDto> {
|
||||
async createBoard(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
memberIds?: string[],
|
||||
): Promise<ActivityBoardDto> {
|
||||
await this.ensureDefaultBoard(organizationId);
|
||||
const max = await this.prisma.activityBoard.aggregate({
|
||||
where: { organizationId },
|
||||
@@ -147,7 +194,8 @@ export class ActivityStagesService {
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
await this.ensureBoardStages(organizationId, board.id);
|
||||
return { ...board };
|
||||
if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
|
||||
return this.getBoardDto(organizationId, board.id);
|
||||
} catch (e) {
|
||||
throw this.mapBoardError(e);
|
||||
}
|
||||
@@ -156,21 +204,51 @@ export class ActivityStagesService {
|
||||
async updateBoard(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string },
|
||||
dto: { name?: string; memberIds?: string[] },
|
||||
): Promise<ActivityBoardDto> {
|
||||
await this.getBoard(organizationId, id);
|
||||
try {
|
||||
const board = await this.prisma.activityBoard.update({
|
||||
where: { id },
|
||||
data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {},
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
return { ...board };
|
||||
if (dto.name != null) {
|
||||
await this.prisma.activityBoard.update({
|
||||
where: { id },
|
||||
data: { name: this.cleanBoardName(dto.name) },
|
||||
});
|
||||
}
|
||||
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
|
||||
return this.getBoardDto(organizationId, id);
|
||||
} catch (e) {
|
||||
throw this.mapBoardError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceMembers(organizationId: string, boardId: string, memberIds: string[]) {
|
||||
const ids = [...new Set(memberIds.map((id) => id.trim()).filter(Boolean))];
|
||||
if (ids.length) {
|
||||
const found = await this.prisma.membership.findMany({
|
||||
where: { organizationId, userId: { in: ids }, isActive: true },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (found.length !== ids.length) {
|
||||
throw new BadRequestException('بعضی از اعضا در این محیط نیستند');
|
||||
}
|
||||
}
|
||||
await this.prisma.activityBoardMember.deleteMany({ where: { boardId } });
|
||||
if (ids.length) {
|
||||
await this.prisma.activityBoardMember.createMany({
|
||||
data: ids.map((userId) => ({ boardId, userId })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getBoardDto(organizationId: string, id: string): Promise<ActivityBoardDto> {
|
||||
const board = await this.prisma.activityBoard.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
|
||||
return toBoardDto(board);
|
||||
}
|
||||
|
||||
/** فعالیتهای گروه حذفشده به گروه پیشفرض (ستون اولِ هموضعیت) منتقل میشوند */
|
||||
async removeBoard(organizationId: string, id: string): Promise<void> {
|
||||
const board = await this.getBoard(organizationId, id);
|
||||
@@ -213,8 +291,14 @@ export class ActivityStagesService {
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> {
|
||||
return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r));
|
||||
async list(
|
||||
organizationId: string,
|
||||
boardId?: string | null,
|
||||
actor?: { userId: string; role: string },
|
||||
): Promise<ActivityStageDto[]> {
|
||||
const resolved = await this.resolveBoardId(organizationId, boardId);
|
||||
if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor);
|
||||
return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
ActivityFieldValue,
|
||||
ActivityFieldValues,
|
||||
ActivityParticipantsValue,
|
||||
ActivityPriority,
|
||||
ActivityPrioritySetting,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
@@ -17,8 +19,11 @@ import type {
|
||||
import {
|
||||
ACTIVITY_FIELD_TYPES,
|
||||
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||
ACTIVITY_PRIORITY_KEYS,
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
|
||||
parseActivityPrioritySettings,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -619,6 +624,43 @@ export class ActivityTypesService {
|
||||
: 'green';
|
||||
}
|
||||
|
||||
async listPriorities(organizationId: string): Promise<ActivityPrioritySetting[]> {
|
||||
const row = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { activityPriorities: true },
|
||||
});
|
||||
return parseActivityPrioritySettings(row?.activityPriorities);
|
||||
}
|
||||
|
||||
async updatePriorities(
|
||||
organizationId: string,
|
||||
items: Array<{ key: ActivityPriority; label: string; color: string }>,
|
||||
): Promise<ActivityPrioritySetting[]> {
|
||||
const byKey = new Map(items.map((i) => [i.key, i]));
|
||||
if (ACTIVITY_PRIORITY_KEYS.some((k) => !byKey.has(k))) {
|
||||
throw new BadRequestException('هر پنج سطح اولویت باید ارسال شوند');
|
||||
}
|
||||
const hex = /^#[0-9a-fA-F]{6}$/;
|
||||
const stored: Record<string, { label: string; color: string }> = {};
|
||||
for (const key of ACTIVITY_PRIORITY_KEYS) {
|
||||
const item = byKey.get(key)!;
|
||||
const label = item.label.trim().slice(0, 40);
|
||||
const color = item.color.trim();
|
||||
if (!label) throw new BadRequestException(`عنوان اولویت «${key}» خالی است`);
|
||||
if (!hex.test(color)) throw new BadRequestException(`رنگ اولویت «${key}» نامعتبر است`);
|
||||
const def = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.find((d) => d.key === key)!;
|
||||
// فقط تفاوت با پیشفرض را نگه میداریم تا JSON جمعوجور بماند
|
||||
if (label !== def.label || color.toLowerCase() !== def.color.toLowerCase()) {
|
||||
stored[key] = { label, color };
|
||||
}
|
||||
}
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { activityPriorities: stored as Prisma.InputJsonValue },
|
||||
});
|
||||
return parseActivityPrioritySettings(stored);
|
||||
}
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
|
||||
@@ -69,11 +71,33 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
/** فیلتر عنوان فعالیت، جدا از جستجوی کلی */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
/** نام شخص یا شرکتِ مشتری */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
customer?: string;
|
||||
|
||||
/** زمان شروع: set = دارد، unset = ندارد */
|
||||
@IsOptional()
|
||||
@IsIn(['set', 'unset'])
|
||||
due?: 'set' | 'unset';
|
||||
|
||||
/** نوع فعالیت (تعریفشده در سازمان) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
typeDefId?: string;
|
||||
|
||||
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_PRIORITIES)
|
||||
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
||||
|
||||
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -131,6 +155,11 @@ export class CreateActivityBoardDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
memberIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateActivityBoardDto {
|
||||
@@ -139,6 +168,11 @@ export class UpdateActivityBoardDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
memberIds?: string[];
|
||||
}
|
||||
|
||||
export class ListActivityStagesQueryDto {
|
||||
@@ -192,6 +226,12 @@ export class ReorderActivityStagesDto {
|
||||
export class MoveActivityStageDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||
@@ -289,7 +329,7 @@ export class CreateActivityDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@MaxLength(20000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -337,6 +377,11 @@ export class CreateActivityDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
leadId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -346,6 +391,14 @@ export class CreateActivityDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'all'])
|
||||
completionMode?: 'any' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
export class UpdateActivityDto {
|
||||
@@ -384,7 +437,7 @@ export class UpdateActivityDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@MaxLength(20000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@@ -432,6 +485,11 @@ export class UpdateActivityDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
leadId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -441,4 +499,35 @@ export class UpdateActivityDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'all'])
|
||||
completionMode?: 'any' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
const PRIORITY_KEYS = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
|
||||
|
||||
export class ActivityPriorityItemDto {
|
||||
@IsIn(PRIORITY_KEYS)
|
||||
key!: (typeof PRIORITY_KEYS)[number];
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^#[0-9a-fA-F]{6}$/)
|
||||
color!: string;
|
||||
}
|
||||
|
||||
export class UpdateActivityPrioritiesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ActivityPriorityItemDto)
|
||||
items!: ActivityPriorityItemDto[];
|
||||
}
|
||||
|
||||
@@ -16,8 +16,13 @@ import type {
|
||||
LoginResponse,
|
||||
LoginResult,
|
||||
OrgModuleKey,
|
||||
StorageUsageDto,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
parseUserTreasuryAccess,
|
||||
permissionsWithTreasuryOverride,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||
import type {} from 'multer';
|
||||
@@ -26,6 +31,8 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { MembershipsService } from '../memberships/memberships.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
||||
import { AttendanceService } from '../attendance/attendance.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
@@ -111,6 +118,8 @@ export class AuthService {
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly storageQuota: StorageQuotaService,
|
||||
private readonly adminAlerts: PlatformAdminAlertService,
|
||||
private readonly attendance: AttendanceService,
|
||||
) {}
|
||||
|
||||
@@ -280,6 +289,12 @@ export class AuthService {
|
||||
where: { id: organization.id },
|
||||
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||
});
|
||||
await this.adminAlerts.notifyNewAccount({
|
||||
source: 'signup',
|
||||
organizationName: organization.name,
|
||||
ownerName: owner.fullName,
|
||||
mobile: owner.mobile,
|
||||
});
|
||||
|
||||
await this.prisma.authChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
@@ -315,11 +330,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
|
||||
if (!ok && !viaMaster) {
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
|
||||
return this.finishLogin(user.id, { fromPassword: true });
|
||||
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
|
||||
}
|
||||
|
||||
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
||||
@@ -613,11 +629,15 @@ export class AuthService {
|
||||
const storageKey = path
|
||||
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
|
||||
const delta = file.size - prevBytes;
|
||||
if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta);
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
if (prevKey && prevKey !== storageKey) {
|
||||
await this.files.remove(prevKey);
|
||||
}
|
||||
await this.writeAvatarKey(userId, storageKey);
|
||||
if (delta) await this.storageQuota.addLocal(organizationId, delta);
|
||||
return this.toAuthUserDto(existing, organizationId);
|
||||
}
|
||||
|
||||
@@ -627,7 +647,11 @@ export class AuthService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const prevKey = await this.readAvatarKey(userId);
|
||||
if (prevKey) await this.files.remove(prevKey);
|
||||
if (prevKey) {
|
||||
const bytes = await this.files.sizeOf(prevKey);
|
||||
await this.files.remove(prevKey);
|
||||
if (bytes) await this.storageQuota.addLocal(organizationId, -bytes);
|
||||
}
|
||||
await this.writeAvatarKey(userId, null);
|
||||
return this.toAuthUserDto(existing, organizationId);
|
||||
}
|
||||
@@ -673,11 +697,14 @@ export class AuthService {
|
||||
selectionToken: string,
|
||||
organizationId: string,
|
||||
): Promise<LoginResponse> {
|
||||
let payload: { sub?: string; typ?: string; pwd?: boolean };
|
||||
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
|
||||
try {
|
||||
payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>(
|
||||
selectionToken,
|
||||
);
|
||||
payload = this.jwt.verify<{
|
||||
sub?: string;
|
||||
typ?: string;
|
||||
pwd?: boolean;
|
||||
master?: boolean;
|
||||
}>(selectionToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
||||
}
|
||||
@@ -686,6 +713,8 @@ export class AuthService {
|
||||
}
|
||||
return this.issueLogin(payload.sub, organizationId, {
|
||||
fromPassword: payload.pwd === true,
|
||||
viaMaster: payload.master === true,
|
||||
skipLastLogin: payload.master === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -698,15 +727,16 @@ export class AuthService {
|
||||
|
||||
private async finishLogin(
|
||||
userId: string,
|
||||
opts?: { fromPassword?: boolean },
|
||||
opts?: { fromPassword?: boolean; viaMaster?: boolean },
|
||||
): Promise<LoginResult> {
|
||||
const rows = await this.memberships.listActive(userId);
|
||||
if (!rows.length) {
|
||||
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
||||
}
|
||||
const usable = opts?.fromPassword
|
||||
? rows.filter((row) => !row.loginOtpOnly)
|
||||
: rows;
|
||||
const usable =
|
||||
opts?.fromPassword && !opts.viaMaster
|
||||
? rows.filter((row) => !row.loginOtpOnly)
|
||||
: rows;
|
||||
if (!usable.length) {
|
||||
throw new UnauthorizedException(
|
||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
||||
@@ -715,6 +745,8 @@ export class AuthService {
|
||||
if (usable.length === 1) {
|
||||
return this.issueLogin(userId, usable[0]!.organization.id, {
|
||||
fromPassword: opts?.fromPassword,
|
||||
viaMaster: opts?.viaMaster,
|
||||
skipLastLogin: opts?.viaMaster,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -724,6 +756,7 @@ export class AuthService {
|
||||
sub: userId,
|
||||
typ: 'org-select',
|
||||
...(opts?.fromPassword ? { pwd: true } : {}),
|
||||
...(opts?.viaMaster ? { master: true } : {}),
|
||||
},
|
||||
{ expiresIn: '10m' },
|
||||
),
|
||||
@@ -731,13 +764,23 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
private async matchesMasterPassword(password: string): Promise<boolean> {
|
||||
if (!password) return false;
|
||||
const row = await this.prisma.platformMasterPassword.findUnique({
|
||||
where: { id: 'default' },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
if (!row?.passwordHash) return false;
|
||||
return bcrypt.compare(password, row.passwordHash);
|
||||
}
|
||||
|
||||
private async issueLogin(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
opts?: { skipLastLogin?: boolean; fromPassword?: boolean },
|
||||
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
|
||||
): Promise<LoginResponse> {
|
||||
const membership = await this.memberships.requireActive(userId, organizationId);
|
||||
if (opts?.fromPassword) {
|
||||
if (opts?.fromPassword && !opts.viaMaster) {
|
||||
const gate = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
select: { loginOtpOnly: true },
|
||||
@@ -809,6 +852,9 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
const role = membership?.role ?? user.role;
|
||||
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||
: [];
|
||||
const orgName = membership?.organization.name;
|
||||
const sub =
|
||||
subscription ??
|
||||
@@ -821,6 +867,12 @@ export class AuthService {
|
||||
canOpenTeamSettings: true,
|
||||
}
|
||||
: undefined;
|
||||
let storage: StorageUsageDto | undefined;
|
||||
try {
|
||||
storage = await this.storageQuota.usageForOrganization(organizationId);
|
||||
} catch {
|
||||
storage = undefined;
|
||||
}
|
||||
return this.toAuthUser({
|
||||
...user,
|
||||
role,
|
||||
@@ -831,11 +883,13 @@ export class AuthService {
|
||||
membership?.organization.enabledModules,
|
||||
),
|
||||
subscription: sub,
|
||||
storage,
|
||||
isOrgOwner: role === 'owner',
|
||||
priorArchive: this.priorArchiveFor(membership),
|
||||
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||
: []) as AuthUserDto['permissions'],
|
||||
permissions: permissionsWithTreasuryOverride(
|
||||
rolePermissions,
|
||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||
) as AuthUserDto['permissions'],
|
||||
...(viewAs ? { viewAs } : {}),
|
||||
});
|
||||
}
|
||||
@@ -941,6 +995,7 @@ export class AuthService {
|
||||
avatarStorageKey?: string | null;
|
||||
enabledModules?: OrgModuleKey[];
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
storage?: StorageUsageDto | null;
|
||||
isOrgOwner?: boolean;
|
||||
priorArchive?: AuthUserDto['priorArchive'];
|
||||
viewAs?: AuthUserDto['viewAs'];
|
||||
@@ -957,6 +1012,7 @@ export class AuthService {
|
||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||
...(user.storage ? { storage: user.storage } : {}),
|
||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
||||
|
||||
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
const peers = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId: { in: memberships.map((m) => m.teamId) },
|
||||
team: { organizationId },
|
||||
user: {
|
||||
memberships: { some: { organizationId, isActive: true } },
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -7,6 +7,19 @@ export type TemplateVars = {
|
||||
formValues?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* مسئول سرنخ فقط از اولین فعالیتی که مسئول دارد پر میشود.
|
||||
* اگر قبلاً گرفته شده یا این فعالیت مسئول ندارد، null برمیگردد.
|
||||
*/
|
||||
export function claimLeadAssignee(
|
||||
alreadyClaimed: boolean,
|
||||
activityAssigneeId: string | null | undefined,
|
||||
): string | null {
|
||||
if (alreadyClaimed) return null;
|
||||
const id = activityAssigneeId?.trim();
|
||||
return id || null;
|
||||
}
|
||||
|
||||
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
||||
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
||||
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
||||
|
||||
@@ -34,6 +34,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import {
|
||||
claimLeadAssignee,
|
||||
formatFormAnswers,
|
||||
pickRoundRobin,
|
||||
renderAutomationTemplate,
|
||||
@@ -59,6 +60,12 @@ export type AutomationContext = {
|
||||
formName?: string | null;
|
||||
formValues?: Record<string, unknown> | null;
|
||||
formFields?: FormFieldLabel[] | null;
|
||||
/**
|
||||
* فرم سرنخ مسئول نوبتی ندارد: مسئول سرنخ همان مسئول اولین فعالیت این اجرا میشود.
|
||||
* داخل همان اجرا یکبار ست میشود تا فعالیت بعدی مسئول را عوض نکند.
|
||||
*/
|
||||
syncLeadAssignee?: boolean;
|
||||
leadAssigneeSynced?: boolean;
|
||||
};
|
||||
|
||||
type AutomationFacts = {
|
||||
@@ -406,6 +413,54 @@ export class AutomationsService {
|
||||
entityId: activity.id,
|
||||
});
|
||||
}
|
||||
await this.syncLeadAssigneeFromActivity(ctx, type === 'call' ? assigneeId : null);
|
||||
}
|
||||
|
||||
/** سرنخ تازهای که فرم برایش مسئول نوبتی ندارد، مسئول اولین تماس را میگیرد. */
|
||||
private async syncLeadAssigneeFromActivity(
|
||||
ctx: AutomationContext,
|
||||
activityAssigneeId: string | null,
|
||||
) {
|
||||
const assigneeId = claimLeadAssignee(ctx.leadAssigneeSynced === true, activityAssigneeId);
|
||||
if (!ctx.syncLeadAssignee || !assigneeId || !ctx.leadId) return;
|
||||
|
||||
const existing = await this.prisma.lead.findFirst({
|
||||
where: {
|
||||
id: ctx.leadId,
|
||||
organizationId: ctx.organizationId,
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { assigneeId: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
ctx.leadAssigneeSynced = true;
|
||||
ctx.assigneeId = assigneeId;
|
||||
if (existing.assigneeId === assigneeId) return;
|
||||
|
||||
await this.prisma.lead.updateMany({
|
||||
where: {
|
||||
id: ctx.leadId,
|
||||
organizationId: ctx.organizationId,
|
||||
archivedAt: null,
|
||||
},
|
||||
data: { assigneeId },
|
||||
});
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: assigneeId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
const name = user?.fullName?.trim() || 'کارشناس';
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: ctx.organizationId,
|
||||
leadId: ctx.leadId,
|
||||
type: 'assigned',
|
||||
title: `به «${name}» اختصاص داده شد`,
|
||||
body: 'مسئول سرنخ همان مسئول اولین تماس اتوماسیون شد.',
|
||||
meta: { assigneeId, source: 'automation' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveAssignee(
|
||||
@@ -523,7 +578,7 @@ export class AutomationsService {
|
||||
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
||||
const wantApp = channel === 'app' || channel === 'both';
|
||||
const wantSms = channel === 'sms' || channel === 'both';
|
||||
const smsText = (config.body?.trim() || title).slice(0, 160);
|
||||
const smsText = (config.body?.trim() || title).trim();
|
||||
const link = entityLink(ctx);
|
||||
|
||||
if (config.notifyTarget === 'person') {
|
||||
@@ -537,9 +592,16 @@ export class AutomationsService {
|
||||
select: { mobile: true, phone: true },
|
||||
})
|
||||
: null;
|
||||
const leadId = ctx.leadId || (ctx.entityType === 'lead' ? ctx.entityId : null);
|
||||
const lead = !person?.mobile && !person?.phone && leadId
|
||||
? await this.prisma.lead.findFirst({
|
||||
where: { id: leadId, organizationId: ctx.organizationId },
|
||||
select: { mobile: true, phone: true },
|
||||
})
|
||||
: null;
|
||||
await this.sendAutomationSms(
|
||||
ctx.organizationId,
|
||||
person?.mobile || person?.phone,
|
||||
person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
|
||||
smsText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingExpiryService {
|
||||
private readonly logger = new Logger(BillingExpiryService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly alerts: PlatformAdminAlertService,
|
||||
) {}
|
||||
|
||||
@Cron('15 * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async handleHourly(): Promise<void> {
|
||||
await this.notifyEnded();
|
||||
}
|
||||
|
||||
async notifyEnded(): Promise<number> {
|
||||
const rows = await this.prisma.billingAccount.findMany({
|
||||
where: {
|
||||
expiryNotifiedAt: null,
|
||||
planCode: { not: 'lifetime' },
|
||||
status: { not: 'suspended' },
|
||||
expiresAt: { lte: new Date() },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await this.alerts.notifySubscriptionEnded(row.id);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`expiry notify failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rows.length) this.logger.log(`subscription expiry checked: ${rows.length}`);
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { BillingExpiryService } from './billing-expiry.service';
|
||||
import { BillingService } from './billing.service';
|
||||
import { OrgModuleGuard } from './org-module.guard';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
import { StorageQuotaService } from './storage-quota.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [BillingService, OrgModuleGuard],
|
||||
exports: [BillingService, OrgModuleGuard],
|
||||
imports: [StorageModule],
|
||||
providers: [
|
||||
BillingService,
|
||||
BillingExpiryService,
|
||||
PlatformAdminAlertService,
|
||||
OrgModuleGuard,
|
||||
StorageQuotaService,
|
||||
],
|
||||
exports: [
|
||||
BillingService,
|
||||
BillingExpiryService,
|
||||
PlatformAdminAlertService,
|
||||
OrgModuleGuard,
|
||||
StorageQuotaService,
|
||||
],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
BillingAccountStatus,
|
||||
BillingPlanCode,
|
||||
OrgModuleKey,
|
||||
OwnerSeatUsageDto,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
@@ -20,8 +21,12 @@ import {
|
||||
defaultEnabledModules,
|
||||
isPermanentBillingPlan,
|
||||
isOrgModuleKey,
|
||||
modulesForProductPlan,
|
||||
PRODUCT_PLAN_LIMITS,
|
||||
type ProductPlanCode,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
|
||||
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
||||
const ms = expiresAt.getTime() - now.getTime();
|
||||
@@ -50,7 +55,10 @@ export function toSummary(row: {
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly adminAlerts: PlatformAdminAlertService,
|
||||
) {}
|
||||
|
||||
toDto(row: {
|
||||
id: string;
|
||||
@@ -60,6 +68,7 @@ export class BillingService {
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
maxUsers?: number | null;
|
||||
externalPaymentRef: string | null;
|
||||
notes: string | null;
|
||||
}): BillingAccountDto {
|
||||
@@ -68,11 +77,71 @@ export class BillingService {
|
||||
id: row.id,
|
||||
ownerUserId: row.ownerUserId,
|
||||
...summary,
|
||||
maxUsers: row.maxUsers ?? null,
|
||||
externalPaymentRef: row.externalPaymentRef,
|
||||
notes: row.notes,
|
||||
};
|
||||
}
|
||||
|
||||
async seatUsage(ownerUserId: string): Promise<OwnerSeatUsageDto> {
|
||||
const account = await this.getByOwnerUserId(ownerUserId);
|
||||
const orgIds = await this.ownedOrganizationIds(ownerUserId);
|
||||
const usedUsers = await this.countActiveUsers(orgIds);
|
||||
return { usedUsers, maxUsers: account?.maxUsers ?? null };
|
||||
}
|
||||
|
||||
async setMaxUsers(ownerUserId: string, maxUsers: number): Promise<OwnerSeatUsageDto> {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { ownerUserId },
|
||||
data: { maxUsers },
|
||||
});
|
||||
return this.seatUsage(ownerUserId);
|
||||
}
|
||||
|
||||
/** کاربر جدید فقط وقتی سقف اشتراک جا دارد. کسی که از قبل در محیطهای همین مالک فعال است صندلی تازه نمیگیرد. */
|
||||
async assertSeatAvailable(organizationId: string, userId: string | null): Promise<void> {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!owner) return;
|
||||
const account = await this.getByOwnerUserId(owner.userId);
|
||||
if (!account || account.maxUsers == null) return;
|
||||
const orgIds = await this.ownedOrganizationIds(owner.userId);
|
||||
if (userId) {
|
||||
const already = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId: { in: orgIds }, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (already) return;
|
||||
}
|
||||
const used = await this.countActiveUsers(orgIds);
|
||||
if (used >= account.maxUsers) {
|
||||
throw new ForbiddenException(
|
||||
`سقف کاربر این اشتراک ${account.maxUsers} نفر است. برای افزودن کاربر باید سقف اشتراک بیشتر شود.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ownedOrganizationIds(ownerUserId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.membership.findMany({
|
||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
return rows.map((row) => row.organizationId);
|
||||
}
|
||||
|
||||
private async countActiveUsers(organizationIds: string[]): Promise<number> {
|
||||
if (!organizationIds.length) return 0;
|
||||
const rows = await this.prisma.membership.findMany({
|
||||
where: { organizationId: { in: organizationIds }, isActive: true },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
});
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async getByOwnerUserId(ownerUserId: string) {
|
||||
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
||||
}
|
||||
@@ -93,6 +162,7 @@ export class BillingService {
|
||||
startsAt: now,
|
||||
expiresAt,
|
||||
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
||||
maxUsers: 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -151,6 +221,7 @@ export class BillingService {
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
}
|
||||
await this.adminAlerts.notifySubscriptionEnded(account.id);
|
||||
throw new UnauthorizedException('اشتراک منقضی شده است');
|
||||
}
|
||||
return toSummary(account);
|
||||
@@ -220,6 +291,10 @@ export class BillingService {
|
||||
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
||||
|
||||
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||
const expiresChanged =
|
||||
!existing || existing.expiresAt.getTime() !== expiresAt.getTime();
|
||||
const expiryNotifiedAt =
|
||||
status === 'active' || expiresChanged ? null : existing?.expiryNotifiedAt ?? null;
|
||||
const row = existing
|
||||
? await this.prisma.billingAccount.update({
|
||||
where: { id: existing.id },
|
||||
@@ -229,6 +304,8 @@ export class BillingService {
|
||||
startsAt: existing.startsAt ?? now,
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
expiryNotifiedAt,
|
||||
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
||||
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
||||
},
|
||||
})
|
||||
@@ -241,8 +318,13 @@ export class BillingService {
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
notes: dto.notes ?? null,
|
||||
expiryNotifiedAt,
|
||||
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
||||
},
|
||||
});
|
||||
if (status === 'expired' && !expiryNotifiedAt) {
|
||||
await this.adminAlerts.notifySubscriptionEnded(row.id);
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -335,7 +417,37 @@ export class BillingService {
|
||||
}
|
||||
|
||||
defaultModulesForNewOrg(): OrgModuleKey[] {
|
||||
return defaultEnabledModules();
|
||||
return modulesForProductPlan('core');
|
||||
}
|
||||
|
||||
/** سطح امکانات را روی همهٔ محیطهای مالک میگذارد و سقف هسته/تیم/سازمان را هم تنظیم میکند. */
|
||||
async applyProductPlan(organizationId: string, code: ProductPlanCode) {
|
||||
const modules = modulesForProductPlan(code);
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
const orgIds = owner ? await this.ownedOrganizationIds(owner.userId) : [organizationId];
|
||||
for (const id of orgIds) {
|
||||
await this.setOrgModules(id, modules);
|
||||
}
|
||||
const limits = PRODUCT_PLAN_LIMITS[code];
|
||||
if (owner && (limits.maxUsers != null || limits.maxOrganizations != null)) {
|
||||
const account = await this.getByOwnerUserId(owner.userId);
|
||||
if (account) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
...(limits.maxUsers != null ? { maxUsers: limits.maxUsers } : {}),
|
||||
...(limits.maxOrganizations != null
|
||||
? { maxOrganizations: limits.maxOrganizations }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
async countExpiringSoon() {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { isPermanentBillingPlan } from '@mento/shared';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
newAccountSms,
|
||||
subscriptionEndedSms,
|
||||
type NewAccountSource,
|
||||
} from './platform-admin-alert';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformAdminAlertService {
|
||||
private readonly logger = new Logger(PlatformAdminAlertService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsGatewayService,
|
||||
) {}
|
||||
|
||||
/** ساخت حساب جدید؛ خطای پیامک جلوی ثبتنام یا ساخت محیط را نمیگیرد. */
|
||||
async notifyNewAccount(input: {
|
||||
source: NewAccountSource;
|
||||
organizationName: string;
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const delivered = await this.sendToAdmins(newAccountSms(input));
|
||||
if (!delivered) {
|
||||
this.logger.warn(`new account SMS was not delivered (${input.source})`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`new account SMS failed: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* یکبار هنگام تمام شدن اشتراک. اگر ارسال نشود پرچم خالی میماند تا بعداً دوباره تلاش شود.
|
||||
*/
|
||||
async notifySubscriptionEnded(billingAccountId: string): Promise<void> {
|
||||
const row = await this.prisma.billingAccount.findUnique({
|
||||
where: { id: billingAccountId },
|
||||
select: {
|
||||
id: true,
|
||||
planCode: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
expiryNotifiedAt: true,
|
||||
owner: {
|
||||
select: {
|
||||
fullName: true,
|
||||
mobile: true,
|
||||
memberships: {
|
||||
where: { role: 'owner', isActive: true },
|
||||
select: { organization: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row || row.expiryNotifiedAt) return;
|
||||
if (row.status === 'suspended') return;
|
||||
if (isPermanentBillingPlan(row.planCode)) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: new Date() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row.expiresAt.getTime() > Date.now() && row.status !== 'expired') return;
|
||||
|
||||
const claimed = await this.prisma.billingAccount.updateMany({
|
||||
where: { id: row.id, expiryNotifiedAt: null },
|
||||
data: { status: 'expired', expiryNotifiedAt: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) return;
|
||||
|
||||
try {
|
||||
const delivered = await this.sendToAdmins(
|
||||
subscriptionEndedSms({
|
||||
organizationNames: row.owner.memberships.map((item) => item.organization.name),
|
||||
ownerName: row.owner.fullName,
|
||||
mobile: row.owner.mobile,
|
||||
planCode: row.planCode,
|
||||
expiresAt: row.expiresAt,
|
||||
}),
|
||||
);
|
||||
if (!delivered) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: null },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`subscription SMS failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
await this.prisma.billingAccount
|
||||
.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: null },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** true یعنی حداقل یک پیامک رفت، یا مدیری با موبایل فعال وجود نداشت. */
|
||||
private async sendToAdmins(text: string): Promise<boolean> {
|
||||
const admins = await this.prisma.platformAdmin.findMany({
|
||||
where: { isActive: true, mobile: { not: null } },
|
||||
select: { mobile: true },
|
||||
});
|
||||
const mobiles = [
|
||||
...new Set(
|
||||
admins
|
||||
.map((admin) => admin.mobile?.trim() ?? '')
|
||||
.filter((mobile) => mobile.length > 0),
|
||||
),
|
||||
];
|
||||
if (!mobiles.length) {
|
||||
this.logger.warn('platform admin SMS skipped: no active admin mobile');
|
||||
return true;
|
||||
}
|
||||
|
||||
let delivered = false;
|
||||
for (const mobile of mobiles) {
|
||||
try {
|
||||
const result = await this.sms.send({
|
||||
phone: mobile,
|
||||
text,
|
||||
source: 'platform-admin',
|
||||
});
|
||||
if (result.ok) delivered = true;
|
||||
else {
|
||||
this.logger.warn(
|
||||
`platform admin SMS failed to=…${mobile.slice(-4)} reason=${result.reason ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`platform admin SMS failed to=…${mobile.slice(-4)}: ${
|
||||
error instanceof Error ? error.message : 'unknown'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BILLING_PLAN_LABELS, type BillingPlanCode } from '@mento/shared';
|
||||
import { formatTehranJalali } from '../common/jalali';
|
||||
|
||||
export type NewAccountSource = 'signup' | 'admin';
|
||||
|
||||
export function newAccountSms(input: {
|
||||
source: NewAccountSource;
|
||||
organizationName: string;
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
}): string {
|
||||
const via = input.source === 'signup' ? 'ثبتنام' : 'پنل ادمین';
|
||||
return [
|
||||
'منتکت',
|
||||
`حساب جدید (${via})`,
|
||||
`محیط: ${input.organizationName.trim() || '—'}`,
|
||||
`مالک: ${input.ownerName.trim() || '—'}`,
|
||||
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function subscriptionEndedSms(input: {
|
||||
organizationNames: string[];
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
planCode: string;
|
||||
expiresAt: Date;
|
||||
}): string {
|
||||
const names = input.organizationNames.map((name) => name.trim()).filter(Boolean);
|
||||
const orgs = names.slice(0, 3).join('، ') || '—';
|
||||
const extra = names.length > 3 ? ` و ${names.length - 3} محیط دیگر` : '';
|
||||
const label =
|
||||
BILLING_PLAN_LABELS[input.planCode as BillingPlanCode] ?? input.planCode;
|
||||
return [
|
||||
'منتکت',
|
||||
'پایان اشتراک',
|
||||
`محیط: ${orgs}${extra}`,
|
||||
`مالک: ${input.ownerName.trim() || '—'}`,
|
||||
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
|
||||
`پلن: ${label}`,
|
||||
`تاریخ: ${formatTehranJalali(input.expiresAt)}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
|
||||
import type { StorageUsageDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
|
||||
function asNumber(value: bigint | number | null | undefined): number {
|
||||
if (value == null) return 0;
|
||||
const n = typeof value === 'bigint' ? Number(value) : value;
|
||||
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
}
|
||||
|
||||
function levelOf(used: number, quota: number | null): StorageUsageDto['level'] {
|
||||
if (quota == null) return 'unlimited';
|
||||
if (quota <= 0 || used >= quota) return 'full';
|
||||
if (used >= quota * 0.8) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageQuotaService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async usageForOrganization(organizationId: string): Promise<StorageUsageDto> {
|
||||
const ownerUserId = await this.ownerUserId(organizationId);
|
||||
if (!ownerUserId) return this.empty();
|
||||
return this.usageForOwner(ownerUserId);
|
||||
}
|
||||
|
||||
async usageForOwner(ownerUserId: string, refresh = false): Promise<StorageUsageDto> {
|
||||
const orgs = await this.ownerOrgs(ownerUserId);
|
||||
if (refresh) {
|
||||
await Promise.all(orgs.map((org) => this.measureLocal(org.id)));
|
||||
} else {
|
||||
await Promise.all(
|
||||
orgs.filter((org) => !org.fileBytesSyncedAt).map((org) => this.measureLocal(org.id)),
|
||||
);
|
||||
}
|
||||
const fresh = refresh || orgs.some((org) => !org.fileBytesSyncedAt)
|
||||
? await this.ownerOrgs(ownerUserId)
|
||||
: orgs;
|
||||
const account = await this.prisma.billingAccount.findUnique({
|
||||
where: { ownerUserId },
|
||||
select: { storageQuotaBytes: true },
|
||||
});
|
||||
const quota = account?.storageQuotaBytes == null ? null : asNumber(account.storageQuotaBytes);
|
||||
const byOrganization = fresh.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
usedBytes: asNumber(org.fileBytes) + asNumber(org.chatFileBytes),
|
||||
}));
|
||||
const used = byOrganization.reduce((sum, org) => sum + org.usedBytes, 0);
|
||||
const percent = quota == null || quota <= 0 ? null : Math.min(100, Math.round((used / quota) * 100));
|
||||
return {
|
||||
usedBytes: used,
|
||||
quotaBytes: quota,
|
||||
percent,
|
||||
level: levelOf(used, quota),
|
||||
byOrganization,
|
||||
};
|
||||
}
|
||||
|
||||
async assertCanAdd(organizationId: string, bytes: number): Promise<void> {
|
||||
if (bytes <= 0) return;
|
||||
const ownerUserId = await this.ownerUserId(organizationId);
|
||||
if (!ownerUserId) return;
|
||||
const usage = await this.usageForOwner(ownerUserId);
|
||||
if (usage.quotaBytes == null) return;
|
||||
if (usage.usedBytes + bytes > usage.quotaBytes) {
|
||||
throw new PayloadTooLargeException(
|
||||
'حجم فایل این اشتراک پر شده است. تا شارژ حجم، آپلود جدید ممکن نیست.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async addLocal(organizationId: string, delta: number): Promise<void> {
|
||||
await this.add(organizationId, 'fileBytes', delta);
|
||||
}
|
||||
|
||||
async addChat(organizationId: string, bytes: number): Promise<StorageUsageDto> {
|
||||
if (bytes > 0) await this.assertCanAdd(organizationId, bytes);
|
||||
if (bytes > 0) await this.add(organizationId, 'chatFileBytes', bytes);
|
||||
return this.usageForOrganization(organizationId);
|
||||
}
|
||||
|
||||
async setQuotaGb(ownerUserId: string, quotaGb: number | null): Promise<StorageUsageDto> {
|
||||
const bytes = quotaGb == null ? null : BigInt(Math.round(quotaGb * GIB));
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { ownerUserId },
|
||||
data: { storageQuotaBytes: bytes },
|
||||
});
|
||||
return this.usageForOwner(ownerUserId, true);
|
||||
}
|
||||
|
||||
private async add(
|
||||
organizationId: string,
|
||||
field: 'fileBytes' | 'chatFileBytes',
|
||||
delta: number,
|
||||
): Promise<void> {
|
||||
if (!delta) return;
|
||||
const row = await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { [field]: { increment: BigInt(Math.round(delta)) } },
|
||||
select: { fileBytes: true, chatFileBytes: true },
|
||||
});
|
||||
const value = field === 'fileBytes' ? row.fileBytes : row.chatFileBytes;
|
||||
if (value < 0n) {
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { [field]: 0n },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ownerUserId(organizationId: string): Promise<string | null> {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
return owner?.userId ?? null;
|
||||
}
|
||||
|
||||
private ownerOrgs(ownerUserId: string) {
|
||||
return this.prisma.membership.findMany({
|
||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||
select: {
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fileBytes: true,
|
||||
chatFileBytes: true,
|
||||
fileBytesSyncedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}).then((rows) => rows.map((row) => row.organization));
|
||||
}
|
||||
|
||||
private async measureLocal(organizationId: string): Promise<void> {
|
||||
const [imageSum, noteSum, treasury, leads, forms, users] = await Promise.all([
|
||||
this.prisma.activityImage.aggregate({
|
||||
where: { organizationId },
|
||||
_sum: { sizeBytes: true },
|
||||
}),
|
||||
this.prisma.noteAttachment.aggregate({
|
||||
where: { organizationId },
|
||||
_sum: { sizeBytes: true },
|
||||
}),
|
||||
this.prisma.financialAttachment.findMany({
|
||||
where: { organizationId },
|
||||
select: { storageKey: true },
|
||||
}),
|
||||
this.prisma.lead.findMany({
|
||||
where: { organizationId, logoStorageKey: { not: null } },
|
||||
select: { logoStorageKey: true },
|
||||
}),
|
||||
this.prisma.leadForm.findMany({
|
||||
where: { organizationId, coverStorageKey: { not: null } },
|
||||
select: { coverStorageKey: true },
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: { avatarStorageKey: { startsWith: `${organizationId}/` } },
|
||||
select: { avatarStorageKey: true },
|
||||
}),
|
||||
]);
|
||||
const keys = [
|
||||
...treasury.map((row) => row.storageKey),
|
||||
...leads.map((row) => row.logoStorageKey).filter((key): key is string => Boolean(key)),
|
||||
...forms.map((row) => row.coverStorageKey).filter((key): key is string => Boolean(key)),
|
||||
...users.map((row) => row.avatarStorageKey).filter((key): key is string => Boolean(key)),
|
||||
];
|
||||
let disk = 0;
|
||||
for (const key of keys) disk += await this.files.sizeOf(key);
|
||||
const bytes = (imageSum._sum.sizeBytes ?? 0) + (noteSum._sum.sizeBytes ?? 0) + disk;
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { fileBytes: BigInt(bytes), fileBytesSyncedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private empty(): StorageUsageDto {
|
||||
return { usedBytes: 0, quotaBytes: null, percent: null, level: 'unlimited' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
ASSIGN_SECTION_PERMISSION,
|
||||
type AssignSection,
|
||||
} from '@mento/shared';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { elevated } from './record-scope';
|
||||
|
||||
const ASSIGN_NOUN: Record<AssignSection, string> = {
|
||||
customers: 'مشتری',
|
||||
leads: 'لید',
|
||||
sales: 'معامله',
|
||||
activities: 'فعالیت',
|
||||
routines: 'کار روتین',
|
||||
projects: 'کارت پروژه',
|
||||
tickets: 'تیکت',
|
||||
};
|
||||
|
||||
type AssignActor = { userId: string; role: string; organizationId: string };
|
||||
|
||||
export function assignOthersDeniedMessage(section: AssignSection): string {
|
||||
return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط میتوانید برای خودتان ثبت کنید.`;
|
||||
}
|
||||
|
||||
export async function actorCanAssignToOthers(
|
||||
prisma: PrismaService,
|
||||
actor: AssignActor,
|
||||
section: AssignSection,
|
||||
): Promise<boolean> {
|
||||
if (elevated(actor.role)) return true;
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: actor.userId,
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = membership?.orgRole?.permissions;
|
||||
const key = ASSIGN_SECTION_PERMISSION[section];
|
||||
return Array.isArray(permissions) && permissions.includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود.
|
||||
* ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است.
|
||||
*/
|
||||
export async function assertAssigneeSet(
|
||||
prisma: PrismaService,
|
||||
actor: AssignActor,
|
||||
section: AssignSection,
|
||||
nextIds: Array<string | null | undefined>,
|
||||
previousIds: Array<string | null | undefined> = [],
|
||||
): Promise<void> {
|
||||
if (await actorCanAssignToOthers(prisma, actor, section)) return;
|
||||
const prev = new Set(
|
||||
previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId),
|
||||
);
|
||||
const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId);
|
||||
const changed = next.length !== prev.size || next.some((id) => !prev.has(id));
|
||||
if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type PositionUpdate = Prisma.PrismaPromise<unknown>;
|
||||
|
||||
/** کارت را در ایندکس خواستهشده میگذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
|
||||
export function placeId(ids: string[], movingId: string, index: number): string[] {
|
||||
const next = ids.filter((id) => id !== movingId);
|
||||
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
|
||||
next.splice(at, 0, movingId);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function sameIds(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||
}
|
||||
|
||||
/** ترتیب ستون را ۰ تا n ذخیره میکند. */
|
||||
export async function writePositions(
|
||||
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
|
||||
ids: string[],
|
||||
update: (id: string, position: number) => PositionUpdate,
|
||||
) {
|
||||
const size = 60;
|
||||
for (let start = 0; start < ids.length; start += size) {
|
||||
const slice = ids.slice(start, start + size);
|
||||
await transaction(slice.map((id, offset) => update(id, start + offset)));
|
||||
}
|
||||
}
|
||||
+143
-109
@@ -62,6 +62,11 @@ export function resolveSectionAccess(
|
||||
if (rolePolicy[sectionKey]) {
|
||||
return { section: rolePolicy[sectionKey]!, source: 'role' };
|
||||
}
|
||||
// فرم محدودهٔ نقش، بخشِ ننوشته را «بدون محدودیت» نشان میدهد.
|
||||
// اگر نقش حتی یک بخش ذخیره کرده باشد، بقیه نباید به دید قدیمی own/team برگردند.
|
||||
if (Object.keys(rolePolicy).length > 0) {
|
||||
return { section: { mode: 'all', rules: [] }, source: 'role' };
|
||||
}
|
||||
if (input.legacyVisibility != null || input.legacyScopes) {
|
||||
return {
|
||||
section: migrateVisibilityToSectionAccess(
|
||||
@@ -100,29 +105,33 @@ export function resolveAllSectionAccess(input: {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ابعاد پرشدهٔ یک قانون با OR؛ خالی = null */
|
||||
function unionFacets<T>(facets: T[]): T | { OR: T[] } | null {
|
||||
if (!facets.length) return null;
|
||||
return facets.length === 1 ? facets[0]! : { OR: facets };
|
||||
}
|
||||
|
||||
export function buildContactRuleWhere(
|
||||
rule: DataAccessRule,
|
||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
||||
): Prisma.PersonWhereInput | null {
|
||||
const and: Prisma.PersonWhereInput[] = [];
|
||||
const ids = ownerIdList(rule, ctx);
|
||||
const facets: Prisma.PersonWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
if (!ids.length) return null;
|
||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
if (rule.groupIds?.length) {
|
||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
||||
const ids = ownerIdList(rule, ctx);
|
||||
if (ids.length) {
|
||||
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
}
|
||||
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئولگروه (personViaGroupAssignee)
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
facets.push({
|
||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||
});
|
||||
}
|
||||
if (rule.moods?.length) {
|
||||
and.push({ mood: { in: rule.moods } });
|
||||
facets.push({ mood: { in: rule.moods } });
|
||||
}
|
||||
if (!and.length) return null;
|
||||
return and.length === 1 ? and[0]! : { AND: and };
|
||||
return unionFacets(facets);
|
||||
}
|
||||
|
||||
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
|
||||
@@ -130,6 +139,23 @@ function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
|
||||
return parts.length === 1 ? parts[0]! : { OR: parts };
|
||||
}
|
||||
|
||||
/** اعضای گروههایی که کاربر مسئولشان است */
|
||||
function personViaGroupAssignee(userId: string): Prisma.PersonWhereInput {
|
||||
return {
|
||||
groupLinks: {
|
||||
some: { group: { assignees: { some: { id: userId } } } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function companyViaGroupAssignee(userId: string): Prisma.CompanyWhereInput {
|
||||
return {
|
||||
groupLinks: {
|
||||
some: { group: { assignees: { some: { id: userId } } } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function customersPersonWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
|
||||
): Prisma.PersonWhereInput {
|
||||
@@ -144,6 +170,8 @@ export function customersPersonWhere(
|
||||
});
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
|
||||
parts.push(personViaGroupAssignee(actor.userId));
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
@@ -155,34 +183,28 @@ export function customersCompanyWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.CompanyWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.CompanyWhereInput[] = [];
|
||||
// حال مشتری روی شرکت معنا ندارد؛ فقط مالک/منبع
|
||||
if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) continue;
|
||||
const facets: Prisma.CompanyWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
if (rule.groupIds?.length) {
|
||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
||||
if (ids.length) {
|
||||
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
}
|
||||
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئولگروه (companyViaGroupAssignee)
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
facets.push({
|
||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule.moods?.length &&
|
||||
!rule.owners?.length &&
|
||||
!rule.groupIds?.length &&
|
||||
!rule.sources?.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
parts.push(companyViaGroupAssignee(actor.userId));
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
@@ -194,27 +216,28 @@ export function leadsDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.LeadWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.LeadWhereInput[] = [];
|
||||
const facets: Prisma.LeadWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
if (ids.length) {
|
||||
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
}
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
facets.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
facets.push({
|
||||
OR: rule.sources.map((s) => ({
|
||||
source: { equals: s, mode: 'insensitive' as const },
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -227,28 +250,29 @@ export function dealsDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.DealWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.DealWhereInput[] = [];
|
||||
const facets: Prisma.DealWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.pipelineIds?.length) {
|
||||
and.push({ pipelineId: { in: rule.pipelineIds } });
|
||||
facets.push({ pipelineId: { in: rule.pipelineIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
facets.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -261,32 +285,33 @@ export function activitiesDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.ActivityWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.ActivityWhereInput[] = [];
|
||||
const facets: Prisma.ActivityWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
{ participantIds: { hasSome: ids } },
|
||||
],
|
||||
});
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
{ participantIds: { hasSome: ids } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
and.push({ boardId: { in: rule.boardIds } });
|
||||
facets.push({ boardId: { in: rule.boardIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
facets.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
and.push({ typeDefId: { in: rule.typeIds } });
|
||||
facets.push({ typeDefId: { in: rule.typeIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -305,34 +330,35 @@ export function notesDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return personalGate;
|
||||
const parts: Prisma.NoteWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.NoteWhereInput[] = [];
|
||||
const facets: Prisma.NoteWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ authorId: { in: ids } },
|
||||
{
|
||||
deal: {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [
|
||||
{ authorId: { in: ids } },
|
||||
{
|
||||
deal: {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ person: { ownerId: { in: ids } } },
|
||||
{ company: { ownerId: { in: ids } } },
|
||||
],
|
||||
});
|
||||
{ person: { ownerId: { in: ids } } },
|
||||
{ company: { ownerId: { in: ids } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({ noteType: { in: rule.sources } });
|
||||
facets.push({ noteType: { in: rule.sources } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return {
|
||||
AND: [personalGate, orParts(parts)],
|
||||
@@ -347,26 +373,27 @@ export function ticketsDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.TicketWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.TicketWhereInput[] = [];
|
||||
const facets: Prisma.TicketWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||
});
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
facets.push({
|
||||
status: {
|
||||
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -379,28 +406,29 @@ export function projectsDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.ProjectCardWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.ProjectCardWhereInput[] = [];
|
||||
const facets: Prisma.ProjectCardWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
and.push({ boardId: { in: rule.boardIds } });
|
||||
facets.push({ boardId: { in: rule.boardIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
facets.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -413,20 +441,21 @@ export function routinesDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.RoutineTaskWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.RoutineTaskWhereInput[] = [];
|
||||
const facets: Prisma.RoutineTaskWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
if (ids.length) {
|
||||
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
}
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
and.push({ categoryId: { in: rule.typeIds } });
|
||||
facets.push({ categoryId: { in: rule.typeIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -444,9 +473,14 @@ export function sectionOwnerIdUnion(
|
||||
if (rule.owners?.length) {
|
||||
sawOwnerRule = true;
|
||||
for (const id of ownerIdList(rule, ctx)) ids.add(id);
|
||||
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
|
||||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
|
||||
rule.typeIds?.length) {
|
||||
} else if (
|
||||
rule.sources?.length ||
|
||||
rule.moods?.length ||
|
||||
rule.stageIds?.length ||
|
||||
rule.pipelineIds?.length ||
|
||||
rule.boardIds?.length ||
|
||||
rule.typeIds?.length
|
||||
) {
|
||||
sawNonOwnerRule = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
SectionDataAccess,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import {
|
||||
activitiesDeepWhere,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
notesDeepWhere,
|
||||
projectsDeepWhere,
|
||||
routinesDeepWhere,
|
||||
sectionOwnerIdUnion,
|
||||
ticketsDeepWhere,
|
||||
} from './data-access';
|
||||
|
||||
@@ -60,6 +61,65 @@ export type Actor = {
|
||||
|
||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||
|
||||
type DeepAccessActor = ScopedActor &
|
||||
Partial<
|
||||
Pick<
|
||||
Actor,
|
||||
| 'customersAccess'
|
||||
| 'leadsAccess'
|
||||
| 'salesAccess'
|
||||
| 'activitiesAccess'
|
||||
| 'notesAccess'
|
||||
| 'routinesAccess'
|
||||
| 'projectsAccess'
|
||||
| 'ticketsAccess'
|
||||
| 'teamPeerIds'
|
||||
>
|
||||
>;
|
||||
|
||||
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
|
||||
switch (scope) {
|
||||
case 'contacts':
|
||||
return actor.customersAccess;
|
||||
case 'leads':
|
||||
return actor.leadsAccess;
|
||||
case 'deals':
|
||||
return actor.salesAccess;
|
||||
case 'activities':
|
||||
return actor.activitiesAccess;
|
||||
case 'notes':
|
||||
return actor.notesAccess;
|
||||
case 'routines':
|
||||
return actor.routinesAccess;
|
||||
case 'projects':
|
||||
return actor.projectsAccess;
|
||||
case 'tickets':
|
||||
return actor.ticketsAccess;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* شناسههای مالک از قانون عمق داده.
|
||||
* legacy = بخش عمیق روی این Actor نیست.
|
||||
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
|
||||
*/
|
||||
function deepOwnerIds(
|
||||
actor: DeepAccessActor,
|
||||
scope: DataScope,
|
||||
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
|
||||
const section = sectionForScope(actor, scope);
|
||||
if (!section) return { kind: 'legacy' };
|
||||
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
|
||||
const ids = sectionOwnerIdUnion(section, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (ids === null) return { kind: 'open' };
|
||||
return { kind: 'ids', ids };
|
||||
}
|
||||
|
||||
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
|
||||
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
|
||||
const s = actor.scopes?.[scope];
|
||||
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
|
||||
|
||||
/** فیلتر مالکیت؛ پیشفرض بخش «مشتریان» (Person / Company). برای فاکتور/پیشفاکتور scope=finance. */
|
||||
export function ownerWhere(
|
||||
actor: ScopedActor,
|
||||
actor: DeepAccessActor,
|
||||
scope: DataScope = 'contacts',
|
||||
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
|
||||
const deep = deepOwnerIds(actor, scope);
|
||||
if (deep.kind === 'open') return {};
|
||||
if (deep.kind === 'ids') {
|
||||
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
|
||||
if (!deep.ids.length) return { ownerId: '__no_access__' };
|
||||
return { ownerId: { in: deep.ids } };
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, scope);
|
||||
if (!ids) return {};
|
||||
if (ids.length === 1) return { ownerId: ids[0]! };
|
||||
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
|
||||
}
|
||||
|
||||
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
||||
export function assigneeWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.ActivityWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
|
||||
const deep = deepOwnerIds(actor, 'activities');
|
||||
const ids =
|
||||
deep.kind === 'open'
|
||||
? null
|
||||
: deep.kind === 'ids'
|
||||
? deep.ids
|
||||
: scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
if (!ids.length) return { assigneeId: '__no_access__' };
|
||||
return {
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
@@ -225,8 +297,10 @@ export function noteAccessWhere(
|
||||
export function resolveOwnerId(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
requested?: string | null,
|
||||
allowOthers?: boolean,
|
||||
): string {
|
||||
if (elevated(actor.role) && requested?.trim()) {
|
||||
const allowed = allowOthers ?? elevated(actor.role);
|
||||
if (allowed && requested?.trim()) {
|
||||
return requested.trim();
|
||||
}
|
||||
return actor.userId;
|
||||
|
||||
@@ -21,15 +21,82 @@ function tehranJalaliLong(d: Date) {
|
||||
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
|
||||
}
|
||||
|
||||
/** قالب بس/بد مثل UI تهحساب — برای توکنهای مانده در پیامک */
|
||||
export function formatSmsBedBes(v: unknown, maximumFractionDigits = 3): string {
|
||||
if (v == null) return '';
|
||||
const s = String(v).trim();
|
||||
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '';
|
||||
const n = Number(s.replace(/,/g, ''));
|
||||
if (!Number.isFinite(n)) return toFaDigits(s);
|
||||
const abs = toFaDigits(
|
||||
new Intl.NumberFormat('en-US', { maximumFractionDigits }).format(Math.abs(n)),
|
||||
);
|
||||
if (n === 0) return abs;
|
||||
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
|
||||
}
|
||||
|
||||
const METAL_FIELD_LABELS: Record<string, string> = {
|
||||
MandeyeVazni: 'طلا',
|
||||
MandeyeVazniNoghre: 'نقره',
|
||||
MandeyeKolBeTALA: 'کل به طلا',
|
||||
MandeMenhayeSeke: 'منهای سکه',
|
||||
};
|
||||
|
||||
function isMetalMandehKey(key: string) {
|
||||
if (METAL_FIELD_LABELS[key]) return true;
|
||||
if (key.startsWith('Seke_')) return true;
|
||||
return /Vazni|طلا|نقره|فلز|Gold|Silver/i.test(key);
|
||||
}
|
||||
|
||||
function metalFieldLabel(key: string) {
|
||||
if (METAL_FIELD_LABELS[key]) return METAL_FIELD_LABELS[key]!;
|
||||
if (key.startsWith('Seke_')) return `سکه ${key.slice(5)}`;
|
||||
return key;
|
||||
}
|
||||
|
||||
/** از فیلدهای مانده تهحساب، متن مانده ریالی و فلزی میسازد */
|
||||
export function smsBalanceTexts(input: {
|
||||
mandehFields?: Array<{ key: string; value: string | null }> | null;
|
||||
rialBalance?: number | null;
|
||||
goldBalance?: number | null;
|
||||
}): { rialBalance: string; metalBalance: string } {
|
||||
const fields = input.mandehFields ?? [];
|
||||
const mali = fields.find((f) => f.key === 'MandeyeMali' || f.key === 'MandeyeKolBePool');
|
||||
const rial =
|
||||
formatSmsBedBes(mali?.value ?? input.rialBalance) ||
|
||||
formatSmsBedBes(input.rialBalance);
|
||||
const metalParts = fields
|
||||
.filter((f) => isMetalMandehKey(f.key) && f.value != null && String(f.value).trim() !== '')
|
||||
.map((f) => `${metalFieldLabel(f.key)} ${formatSmsBedBes(f.value)}`.trim())
|
||||
.filter(Boolean);
|
||||
const metal =
|
||||
metalParts.join('، ') ||
|
||||
(input.goldBalance != null && formatSmsBedBes(input.goldBalance)
|
||||
? `طلا ${formatSmsBedBes(input.goldBalance)}`
|
||||
: '');
|
||||
return { rialBalance: rial, metalBalance: metal };
|
||||
}
|
||||
|
||||
export function renderSmsText(
|
||||
text: string,
|
||||
vars: { name?: string; phone?: string; title?: string; company?: string },
|
||||
vars: {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
title?: string;
|
||||
company?: string;
|
||||
from?: string;
|
||||
rialBalance?: string;
|
||||
metalBalance?: string;
|
||||
},
|
||||
) {
|
||||
return applyMessageTemplate(text, {
|
||||
name: vars.name,
|
||||
phone: vars.phone,
|
||||
title: vars.title,
|
||||
company: vars.company,
|
||||
from: vars.from,
|
||||
rialBalance: vars.rialBalance,
|
||||
metalBalance: vars.metalBalance,
|
||||
date: tehranJalaliLong(new Date()),
|
||||
}).trim();
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), pr
|
||||
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
|
||||
const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
|
||||
return {
|
||||
year: today.jy,
|
||||
month: today.jm,
|
||||
monthName: jalaliMonthName(today.jm),
|
||||
today: today.jd,
|
||||
|
||||
@@ -51,6 +51,11 @@ export class CompaniesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('group-stats')
|
||||
groupStats(@CurrentUser() user: Actor) {
|
||||
return this.companiesService.groupStats(user);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateCompanyRequest,
|
||||
CustomerGroupDto,
|
||||
PaginatedResponse,
|
||||
UpdateCompanyRequest,
|
||||
} from '@mento/shared';
|
||||
@@ -19,10 +20,10 @@ import {
|
||||
import { customersCompanyWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
elevated,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||
@@ -111,9 +112,13 @@ export class CompaniesService {
|
||||
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
||||
];
|
||||
const where: Prisma.CompanyWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
...customersCompanyWhere(actor),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
...and,
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
@@ -135,6 +140,39 @@ export class CompaniesService {
|
||||
};
|
||||
}
|
||||
|
||||
/** آمار گروه برای فیلتر شرکتها — فقط شرکتهای در دسترس کاربر */
|
||||
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
|
||||
const counts = await this.prisma.companyGroupLink.groupBy({
|
||||
by: ['groupId'],
|
||||
where: {
|
||||
company: {
|
||||
organizationId: actor.organizationId,
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
if (!counts.length) return [];
|
||||
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
||||
const groups = await this.prisma.customerGroup.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
id: { in: counts.map((c) => c.groupId) },
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
color: g.color,
|
||||
position: g.position,
|
||||
fromTahesab: Boolean(g.tahesabGid),
|
||||
personCount: 0,
|
||||
companyCount: countMap.get(g.id) ?? 0,
|
||||
assignees: [],
|
||||
}));
|
||||
}
|
||||
|
||||
async get(actor: Actor, id: string): Promise<CompanyDto> {
|
||||
const row = await this.prisma.company.findFirst({
|
||||
where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
|
||||
@@ -168,7 +206,11 @@ export class CompaniesService {
|
||||
notes: emptyToNull(dto.notes),
|
||||
registeredAt: parseDateOrNull(dto.registeredAt),
|
||||
tags: normalizeTags(dto.tags),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId: resolveOwnerId(
|
||||
actor,
|
||||
dto.ownerId,
|
||||
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
||||
),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
};
|
||||
await this.assertNoDuplicate(actor.organizationId, data);
|
||||
@@ -233,8 +275,14 @@ export class CompaniesService {
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
if (dto.ownerId !== undefined) {
|
||||
const current = await this.prisma.company.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
||||
data.ownerId = nextOwner ?? actor.userId;
|
||||
}
|
||||
|
||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||
|
||||
@@ -3,11 +3,13 @@ import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { PublicApiModule } from '../public-api/public-api.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { SameMobileMergeService } from './same-mobile-merge.service';
|
||||
import { BehaviorModule } from '../behavior/behavior.module';
|
||||
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -19,9 +21,10 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
|
||||
PublicApiModule,
|
||||
BehaviorModule,
|
||||
CustomerGroupsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
@@ -15,13 +16,18 @@ import {
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
||||
import { PERSON_GENDERS, PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
||||
|
||||
export class BirthdaySmsDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(500)
|
||||
text!: string;
|
||||
|
||||
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class BulkDeletePeopleDto {
|
||||
@@ -282,6 +288,14 @@ export class CreatePersonDto {
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PERSON_GENDERS)
|
||||
gender?: (typeof PERSON_GENDERS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showGenderInName?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -401,6 +415,14 @@ export class UpdatePersonDto {
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PERSON_GENDERS)
|
||||
gender?: (typeof PERSON_GENDERS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showGenderInName?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
|
||||
|
||||
const personSelect = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
gender: true,
|
||||
showGenderInName: true,
|
||||
previousFirstName: true,
|
||||
previousLastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
position: true,
|
||||
website: true,
|
||||
instagram: true,
|
||||
birthDate: true,
|
||||
referrer: true,
|
||||
addresses: true,
|
||||
notes: true,
|
||||
tags: true,
|
||||
companyId: true,
|
||||
customFields: true,
|
||||
importSource: true,
|
||||
sourceRef: true,
|
||||
previousImportSource: true,
|
||||
previousSourceRef: true,
|
||||
loyaltyPoints: true,
|
||||
discLabel: true,
|
||||
discReliable: true,
|
||||
discAssessedAt: true,
|
||||
} satisfies Prisma.PersonSelect;
|
||||
|
||||
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
|
||||
|
||||
function blank(value: string | null | undefined) {
|
||||
return !value?.trim();
|
||||
}
|
||||
|
||||
function asStrings(value: Prisma.JsonValue): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return { ...(value as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
|
||||
const notes = [keeper.notes?.trim() || ''];
|
||||
let email = keeper.email;
|
||||
let phone = storedPhoneNumber(keeper.phone);
|
||||
let position = keeper.position;
|
||||
let website = keeper.website;
|
||||
let instagram = keeper.instagram;
|
||||
let birthDate = keeper.birthDate;
|
||||
let referrer = keeper.referrer;
|
||||
let companyId = keeper.companyId;
|
||||
let gender = keeper.gender;
|
||||
let showGenderInName = keeper.showGenderInName;
|
||||
let previousFirstName = keeper.previousFirstName;
|
||||
let previousLastName = keeper.previousLastName;
|
||||
let previousImportSource = keeper.previousImportSource;
|
||||
let previousSourceRef = keeper.previousSourceRef;
|
||||
let discLabel = keeper.discLabel;
|
||||
let discReliable = keeper.discReliable;
|
||||
let discAssessedAt = keeper.discAssessedAt;
|
||||
let loyaltyPoints = keeper.loyaltyPoints;
|
||||
const addresses = asStrings(keeper.addresses);
|
||||
const tags = new Set(keeper.tags);
|
||||
const customFields = asObject(keeper.customFields);
|
||||
|
||||
for (const extra of extras) {
|
||||
const name = `${extra.firstName} ${extra.lastName}`.trim();
|
||||
notes.push(
|
||||
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
|
||||
);
|
||||
if (blank(email) && extra.email) email = extra.email;
|
||||
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
|
||||
if (blank(position) && extra.position) position = extra.position;
|
||||
if (blank(website) && extra.website) website = extra.website;
|
||||
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
|
||||
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
|
||||
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
|
||||
if (!companyId && extra.companyId) companyId = extra.companyId;
|
||||
if (gender === 'none' && extra.gender !== 'none') {
|
||||
gender = extra.gender;
|
||||
showGenderInName = extra.showGenderInName;
|
||||
}
|
||||
if (blank(previousFirstName) && extra.previousFirstName) {
|
||||
previousFirstName = extra.previousFirstName;
|
||||
}
|
||||
if (blank(previousLastName) && extra.previousLastName) {
|
||||
previousLastName = extra.previousLastName;
|
||||
}
|
||||
if (
|
||||
blank(previousImportSource) &&
|
||||
extra.importSource &&
|
||||
extra.importSource !== keeper.importSource
|
||||
) {
|
||||
previousImportSource = extra.importSource;
|
||||
previousSourceRef = extra.sourceRef;
|
||||
}
|
||||
if (
|
||||
keeper.importSource === 'tahesab' &&
|
||||
blank(previousFirstName) &&
|
||||
blank(previousLastName)
|
||||
) {
|
||||
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
|
||||
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
|
||||
if (extraName && extraName !== keeperName) {
|
||||
previousFirstName = extra.firstName || null;
|
||||
previousLastName = extra.lastName || null;
|
||||
}
|
||||
}
|
||||
if (!discLabel && extra.discLabel) {
|
||||
discLabel = extra.discLabel;
|
||||
discReliable = extra.discReliable;
|
||||
discAssessedAt = extra.discAssessedAt;
|
||||
}
|
||||
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
|
||||
for (const address of asStrings(extra.addresses)) {
|
||||
if (!addresses.includes(address)) addresses.push(address);
|
||||
}
|
||||
for (const tag of extra.tags) tags.add(tag);
|
||||
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
|
||||
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mobile: canon,
|
||||
email,
|
||||
phone,
|
||||
position,
|
||||
website,
|
||||
instagram,
|
||||
birthDate,
|
||||
referrer,
|
||||
companyId,
|
||||
gender,
|
||||
showGenderInName,
|
||||
previousFirstName,
|
||||
previousLastName,
|
||||
previousImportSource,
|
||||
previousSourceRef,
|
||||
discLabel,
|
||||
discReliable,
|
||||
discAssessedAt,
|
||||
loyaltyPoints,
|
||||
addresses,
|
||||
tags: [...tags],
|
||||
customFields: customFields as Prisma.InputJsonValue,
|
||||
notes: notes.filter(Boolean).join('\n') || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
|
||||
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
|
||||
SELECT table_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND column_name = 'personId'
|
||||
`;
|
||||
return rows
|
||||
.map((row) => row.table_name)
|
||||
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
|
||||
}
|
||||
|
||||
async function moveLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
tables: string[],
|
||||
keeperId: string,
|
||||
dupId: string,
|
||||
) {
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PersonGroupLink" AS d
|
||||
USING "PersonGroupLink" AS k
|
||||
WHERE d."personId" = ${dupId}
|
||||
AND k."personId" = ${keeperId}
|
||||
AND d."groupId" = k."groupId"
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PersonAccountingLink" AS d
|
||||
USING "PersonAccountingLink" AS k
|
||||
WHERE d."personId" = ${dupId}
|
||||
AND k."personId" = ${keeperId}
|
||||
AND d."connectionId" = k."connectionId"
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PartyOpeningBalance"
|
||||
WHERE "personId" = ${dupId}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
|
||||
)
|
||||
`;
|
||||
|
||||
for (const table of tables) {
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
|
||||
keeperId,
|
||||
dupId,
|
||||
);
|
||||
}
|
||||
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "MontifyLink"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Notification"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "GamificationTransaction"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "FinancialAuditLog"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function mergeSameMobilePeople(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
apply: boolean,
|
||||
) {
|
||||
const people = await prisma.person.findMany({
|
||||
where: { organizationId },
|
||||
select: personSelect,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const groups = new Map<string, PersonRow[]>();
|
||||
for (const person of people) {
|
||||
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||
if (!key) continue;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(person);
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
|
||||
const preview = duplicateGroups.map(([mobile, list]) => ({
|
||||
mobile,
|
||||
people: list.map((person) => ({
|
||||
id: person.id,
|
||||
stored: person.mobile,
|
||||
name: `${person.firstName} ${person.lastName}`.trim(),
|
||||
})),
|
||||
}));
|
||||
|
||||
if (!apply) {
|
||||
const needsRewrite = people.filter((person) => {
|
||||
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
|
||||
}).length;
|
||||
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
|
||||
}
|
||||
|
||||
const tables = await personIdTables(prisma);
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const [canon, list] of groups) {
|
||||
const ordered = [...list].sort((a, b) => {
|
||||
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
|
||||
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
|
||||
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
|
||||
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
|
||||
});
|
||||
const [keeper, ...extras] = ordered;
|
||||
if (!keeper) continue;
|
||||
if (!extras.length) {
|
||||
const phoneCanon = canonicalIranMobile(keeper.phone);
|
||||
const data: Prisma.PersonUpdateInput = {};
|
||||
if (keeper.mobile !== canon) data.mobile = canon;
|
||||
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
|
||||
if (Object.keys(data).length) {
|
||||
await prisma.person.update({ where: { id: keeper.id }, data });
|
||||
rewritten += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.person.update({
|
||||
where: { id: keeper.id },
|
||||
data: mergedFields(keeper, extras, canon),
|
||||
});
|
||||
for (const extra of extras) {
|
||||
await moveLinks(tx, tables, keeper.id, extra.id);
|
||||
await tx.person.delete({ where: { id: extra.id } });
|
||||
merged += 1;
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(
|
||||
`merge mobile ${canon} failed`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
|
||||
}
|
||||
|
||||
function loadEnv(file: string) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
|
||||
if (!match || process.env[match[1]]) continue;
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[match[1]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
|
||||
if (direct) {
|
||||
loadEnv(resolve(__dirname, '../../.env'));
|
||||
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
|
||||
const apply = process.argv.includes('--apply');
|
||||
if (!orgId) {
|
||||
console.error('org id required: --org=<id>');
|
||||
process.exit(1);
|
||||
}
|
||||
const prisma = new PrismaClient();
|
||||
mergeSameMobilePeople(prisma, orgId, apply)
|
||||
.then((result) => {
|
||||
console.log(JSON.stringify({
|
||||
apply,
|
||||
merged: result.merged,
|
||||
rewritten: result.rewritten,
|
||||
needsRewrite: result.needsRewrite,
|
||||
groupCount: result.groups.length,
|
||||
groups: result.groups,
|
||||
}, null, 2));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
}
|
||||
@@ -1,19 +1,62 @@
|
||||
export function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeEmail(value?: string | null): string | null {
|
||||
const v = emptyToNull(value);
|
||||
return v ? v.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** Keep digits and leading + for Iranian/international numbers. */
|
||||
export function normalizePhone(value?: string | null): string | null {
|
||||
const v = emptyToNull(value);
|
||||
if (!v) return null;
|
||||
const cleaned = v.replace(/[^\d+]/g, '');
|
||||
return cleaned.length ? cleaned : null;
|
||||
}
|
||||
|
||||
export function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeEmail(value?: string | null): string | null {
|
||||
const v = emptyToNull(value);
|
||||
return v ? v.toLowerCase() : null;
|
||||
}
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
/** ارقام فارسی و عربی را به لاتین برمیگرداند تا از شماره حذف نشوند. */
|
||||
function latinDigits(value: string): string {
|
||||
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const fa = FA_DIGITS.indexOf(ch);
|
||||
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep digits and leading + for Iranian/international numbers. */
|
||||
export function normalizePhone(value?: string | null): string | null {
|
||||
const v = emptyToNull(value);
|
||||
if (!v) return null;
|
||||
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
|
||||
return cleaned.length ? cleaned : null;
|
||||
}
|
||||
|
||||
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شمارهاند. */
|
||||
export function canonicalIranMobile(value?: string | null): string | null {
|
||||
const n = normalizePhone(value);
|
||||
if (!n) return null;
|
||||
let d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
|
||||
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
|
||||
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||
return /^09\d{9}$/.test(d) ? d : null;
|
||||
}
|
||||
|
||||
/** شکلهایی که ممکن است همین موبایل با آنها ذخیره شده باشد. */
|
||||
export function iranMobileLookupKeys(value?: string | null): string[] {
|
||||
const canon = canonicalIranMobile(value);
|
||||
if (!canon) return [];
|
||||
const core = canon.slice(1);
|
||||
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
|
||||
}
|
||||
|
||||
/** برای تطبیق: اگر موبایل ایران است همه شکلها، وگرنه همان متن نرمالشده. */
|
||||
export function phoneIdentityKeys(value?: string | null): string[] {
|
||||
const keys = iranMobileLookupKeys(value);
|
||||
if (keys.length) return keys;
|
||||
const n = normalizePhone(value);
|
||||
return n ? [n] : [];
|
||||
}
|
||||
|
||||
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شمارهها فقط تمیز میشوند. */
|
||||
export function storedPhoneNumber(value?: string | null): string | null {
|
||||
return canonicalIranMobile(value) ?? normalizePhone(value);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ export class PeopleController {
|
||||
return this.peopleService.sourceStats(user);
|
||||
}
|
||||
|
||||
@Get('group-stats')
|
||||
groupStats(@CurrentUser() user: Actor) {
|
||||
return this.peopleService.groupStats(user);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -134,6 +139,7 @@ export class PeopleController {
|
||||
}
|
||||
|
||||
@Get('birthdays')
|
||||
@RequirePermission('customers', { reads: true })
|
||||
birthdays(@CurrentUser() user: Actor) {
|
||||
return this.peopleService.birthdaysThisMonth(user);
|
||||
}
|
||||
@@ -166,7 +172,7 @@ export class PeopleController {
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BirthdaySmsDto,
|
||||
) {
|
||||
return this.peopleService.sendBirthdaySms(user, id, dto.text);
|
||||
return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreatePersonRequest,
|
||||
CustomerGroupDto,
|
||||
FilteredIdsResponse,
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
@@ -17,12 +18,23 @@ import type {
|
||||
PersonMood,
|
||||
UpdatePersonRequest,
|
||||
} from '@mento/shared';
|
||||
import { FILTERED_IDS_MAX, PERSON_MOODS } from '@mento/shared';
|
||||
import {
|
||||
FILTERED_IDS_MAX,
|
||||
PERSON_MOODS,
|
||||
applyPersonGenderTitle,
|
||||
canViewAllRecords,
|
||||
splitPersonHonorific,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||
import {
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from './normalize';
|
||||
import {
|
||||
asAddresses,
|
||||
asCustomFields,
|
||||
@@ -32,18 +44,20 @@ import {
|
||||
import { customersPersonWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { parseBirthDate } from '../common/excel';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { selectMonthBirthdays } from './birthday-month';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
import { renderSmsText, smsBalanceTexts } from '../common/sms-text';
|
||||
import { extractMandehRaw } from '../integrations/tahesab/tahesab-methods';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||
import {
|
||||
@@ -72,6 +86,8 @@ type PersonRow = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
gender: string;
|
||||
showGenderInName: boolean;
|
||||
previousFirstName: string | null;
|
||||
previousLastName: string | null;
|
||||
importSource: string;
|
||||
@@ -144,6 +160,7 @@ export class PeopleService implements OnModuleInit {
|
||||
private readonly outboundSms: OutboundSmsLogService,
|
||||
private readonly behaviorAccess: BehaviorAccessService,
|
||||
private readonly groups: CustomerGroupsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
private personListWhere(
|
||||
@@ -203,13 +220,27 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
const disc = discFilter(opts?.disc);
|
||||
if (disc) and.push(disc);
|
||||
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
||||
// گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR
|
||||
const facets: Prisma.PersonWhereInput[] = [];
|
||||
if (groupIds.length) {
|
||||
facets.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
||||
}
|
||||
if (moods.length) {
|
||||
facets.push({ mood: { in: moods } });
|
||||
}
|
||||
if (sources.length) {
|
||||
facets.push({ importSource: { in: sources } });
|
||||
}
|
||||
if (facets.length === 1) and.push(facets[0]!);
|
||||
else if (facets.length > 1) and.push({ OR: facets });
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
...(moods.length ? { mood: { in: moods } } : {}),
|
||||
...(sources.length ? { importSource: { in: sources } } : {}),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
...and,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,6 +334,39 @@ export class PeopleService implements OnModuleInit {
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/** آمار گروه برای فیلتر اشخاص — فقط افراد در دسترس کاربر */
|
||||
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
|
||||
const counts = await this.prisma.personGroupLink.groupBy({
|
||||
by: ['groupId'],
|
||||
where: {
|
||||
person: {
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
if (!counts.length) return [];
|
||||
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
||||
const groups = await this.prisma.customerGroup.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
id: { in: counts.map((c) => c.groupId) },
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
color: g.color,
|
||||
position: g.position,
|
||||
fromTahesab: Boolean(g.tahesabGid),
|
||||
personCount: countMap.get(g.id) ?? 0,
|
||||
companyCount: 0,
|
||||
assignees: [],
|
||||
}));
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
void this.backfillStoredBirthDates();
|
||||
}
|
||||
@@ -359,7 +423,10 @@ export class PeopleService implements OnModuleInit {
|
||||
input: { mobile?: string | null; email?: string | null; birthDate: Date },
|
||||
) {
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (input.mobile) or.push({ mobile: input.mobile });
|
||||
const mobileKeys = phoneIdentityKeys(input.mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (input.email) or.push({ email: input.email });
|
||||
if (!or.length) return;
|
||||
await this.prisma.person.updateMany({
|
||||
@@ -384,17 +451,25 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
||||
const named = applyPersonGenderTitle({
|
||||
firstName: dto.firstName ?? '',
|
||||
lastName: dto.lastName ?? '',
|
||||
gender: dto.gender,
|
||||
showGenderInName: dto.showGenderInName,
|
||||
});
|
||||
const data: Prisma.PersonUncheckedCreateInput = {
|
||||
organizationId: actor.organizationId,
|
||||
firstName: (dto.firstName ?? '').trim(),
|
||||
lastName: (dto.lastName ?? '').trim(),
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
previousFirstName: emptyToNull(dto.previousFirstName),
|
||||
previousLastName: emptyToNull(dto.previousLastName),
|
||||
importSource: normalizePersonImportSource(dto.importSource),
|
||||
sourceRef: emptyToNull(dto.sourceRef),
|
||||
email: normalizeEmail(dto.email),
|
||||
phone: normalizePhone(dto.phone),
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
phone: storedPhoneNumber(dto.phone),
|
||||
mobile: storedPhoneNumber(dto.mobile),
|
||||
position: emptyToNull(dto.position),
|
||||
website: emptyToNull(dto.website),
|
||||
instagram: emptyToNull(dto.instagram),
|
||||
@@ -405,7 +480,11 @@ export class PeopleService implements OnModuleInit {
|
||||
mood: dto.mood ?? 'newcomer',
|
||||
tags: normalizeTags(dto.tags),
|
||||
companyId,
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId: resolveOwnerId(
|
||||
actor,
|
||||
dto.ownerId,
|
||||
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
||||
),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
...(registeredAt && !Number.isNaN(registeredAt.getTime())
|
||||
? { createdAt: registeredAt }
|
||||
@@ -464,8 +543,32 @@ export class PeopleService implements OnModuleInit {
|
||||
await this.ensureExists(actor, id);
|
||||
|
||||
const data: Prisma.PersonUncheckedUpdateInput = {};
|
||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
||||
const touchesName =
|
||||
dto.firstName !== undefined ||
|
||||
dto.lastName !== undefined ||
|
||||
dto.gender !== undefined ||
|
||||
dto.showGenderInName !== undefined;
|
||||
if (touchesName) {
|
||||
const existing = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { firstName: true, lastName: true, gender: true, showGenderInName: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('شخص پیدا نشد');
|
||||
const bare = splitPersonHonorific(existing.firstName, existing.lastName);
|
||||
const storedGender =
|
||||
existing.gender === 'male' || existing.gender === 'female' ? existing.gender : 'none';
|
||||
const named = applyPersonGenderTitle({
|
||||
firstName: dto.firstName !== undefined ? dto.firstName : bare.firstName,
|
||||
lastName: dto.lastName !== undefined ? dto.lastName : bare.lastName,
|
||||
gender: dto.gender !== undefined ? dto.gender : storedGender,
|
||||
showGenderInName:
|
||||
dto.showGenderInName !== undefined ? dto.showGenderInName : existing.showGenderInName,
|
||||
});
|
||||
data.firstName = named.firstName;
|
||||
data.lastName = named.lastName;
|
||||
data.gender = named.gender;
|
||||
data.showGenderInName = named.showGenderInName;
|
||||
}
|
||||
if ('previousFirstName' in dto) {
|
||||
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
||||
}
|
||||
@@ -477,8 +580,8 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
||||
if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
|
||||
if ('position' in dto) data.position = emptyToNull(dto.position);
|
||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
|
||||
@@ -502,8 +605,14 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
data.companyId = nextCompanyId;
|
||||
}
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
if (dto.ownerId !== undefined) {
|
||||
const current = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
||||
data.ownerId = nextOwner ?? actor.userId;
|
||||
}
|
||||
|
||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||
@@ -559,10 +668,13 @@ export class PeopleService implements OnModuleInit {
|
||||
) {
|
||||
const email = typeof data.email === 'string' ? data.email : null;
|
||||
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (!or.length) return;
|
||||
|
||||
const clash = await this.prisma.person.findFirst({
|
||||
@@ -587,8 +699,8 @@ export class PeopleService implements OnModuleInit {
|
||||
const rows = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
ownerId: actor.userId,
|
||||
birthDate: { not: null },
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -635,27 +747,50 @@ export class PeopleService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
async sendBirthdaySms(actor: Actor, personId: string, text: string) {
|
||||
async sendBirthdaySms(actor: Actor, personId: string, text: string, force = false) {
|
||||
const body = text.trim();
|
||||
if (!body || body.length > 500) {
|
||||
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
||||
}
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId: actor.organizationId },
|
||||
where: {
|
||||
id: personId,
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
phone: true,
|
||||
position: true,
|
||||
ownerId: true,
|
||||
birthDate: true,
|
||||
company: { select: { name: true } },
|
||||
groupLinks: {
|
||||
select: {
|
||||
group: {
|
||||
select: {
|
||||
assignees: { select: { id: true, fullName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!person) throw new NotFoundException('مشتری پیدا نشد');
|
||||
if (person.ownerId !== actor.userId) {
|
||||
throw new ForbiddenException('فقط مسئول این مشتری میتواند پیامک تبریک بفرستد.');
|
||||
const assigneeIds = new Set(
|
||||
person.groupLinks.flatMap((link) => link.group.assignees.map((a) => a.id)),
|
||||
);
|
||||
const canSend =
|
||||
canViewAllRecords(actor.role) ||
|
||||
person.ownerId === actor.userId ||
|
||||
assigneeIds.has(actor.userId);
|
||||
if (!canSend) {
|
||||
throw new ForbiddenException(
|
||||
'فقط مالک مشتری یا مسئول گروه او میتواند پیامک تبریک بفرستد.',
|
||||
);
|
||||
}
|
||||
if (!person.birthDate) {
|
||||
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
|
||||
@@ -677,12 +812,110 @@ export class PeopleService implements OnModuleInit {
|
||||
const mobile = person.mobile || person.phone;
|
||||
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
||||
const fullName = `${person.firstName} ${person.lastName}`.trim();
|
||||
const message = renderSmsText(body, {
|
||||
name: fullName,
|
||||
const birthdayKey = `birthday:${inMonth.year}-${inMonth.month}`;
|
||||
if (!force) {
|
||||
const prior = await this.prisma.outboundSmsLog.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
personId: person.id,
|
||||
status: 'sent',
|
||||
sourceId: birthdayKey,
|
||||
NOT: { actorUserId: actor.userId },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { actorUserId: true },
|
||||
});
|
||||
if (prior) {
|
||||
const priorUser = prior.actorUserId
|
||||
? await this.prisma.user.findFirst({
|
||||
where: { id: prior.actorUserId },
|
||||
select: { fullName: true },
|
||||
})
|
||||
: null;
|
||||
const who = priorUser?.fullName?.trim() || 'مسئول دیگری';
|
||||
throw new ConflictException(
|
||||
`${who} قبلاً پیامک تبریک این مشتری را فرستاده است. آیا میخواهید دوباره بفرستید؟`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = renderSmsText(
|
||||
body,
|
||||
await this.personSmsVars(actor.organizationId, person.id, {
|
||||
name: fullName,
|
||||
phone: mobile,
|
||||
title: person.position?.trim() || '',
|
||||
company: person.company?.name ?? '',
|
||||
}),
|
||||
);
|
||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||
const result = await this.sms.send({
|
||||
organizationId: actor.organizationId,
|
||||
phone: mobile,
|
||||
title: fullName,
|
||||
company: person.company?.name ?? '',
|
||||
text: message,
|
||||
source: 'manual',
|
||||
});
|
||||
await this.outboundSms.record({
|
||||
organizationId: actor.organizationId,
|
||||
personId: person.id,
|
||||
mobile,
|
||||
body: message,
|
||||
source: 'manual',
|
||||
sourceId: birthdayKey,
|
||||
status: result.ok ? 'sent' : 'failed',
|
||||
error: result.ok ? null : result.reason,
|
||||
actorUserId: actor.userId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
result.blocked
|
||||
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
||||
: result.reason === 'SMS not configured'
|
||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
const senderName = (
|
||||
await this.prisma.user.findFirst({
|
||||
where: { id: actor.userId },
|
||||
select: { fullName: true },
|
||||
})
|
||||
)?.fullName?.trim() || 'یکی از مسئولان';
|
||||
const peers = [...assigneeIds].filter((id) => id !== actor.userId);
|
||||
await Promise.all(
|
||||
peers.map((userId) =>
|
||||
this.notifications.create({
|
||||
organizationId: actor.organizationId,
|
||||
userId,
|
||||
type: 'birthday_sms_sent',
|
||||
title: 'پیامک تبریک تولد ارسال شد',
|
||||
body: `${senderName} برای «${fullName}» پیامک تبریک فرستاد. اگر لازم است میتوانید دوباره بفرستید.`,
|
||||
link: `/contacts/people/${person.id}`,
|
||||
entityType: 'person',
|
||||
entityId: person.id,
|
||||
dedupe: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async sendSms(actor: Actor, personId: string, text: string) {
|
||||
const body = text.trim();
|
||||
if (!body || body.length > 500) {
|
||||
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
||||
}
|
||||
const person = await this.get(actor, personId);
|
||||
const mobile = person.mobile || person.phone;
|
||||
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
||||
const message = renderSmsText(
|
||||
body,
|
||||
await this.personSmsVars(actor.organizationId, person.id, {
|
||||
name: person.fullName,
|
||||
phone: mobile,
|
||||
title: person.position?.trim() || '',
|
||||
company: person.company?.name ?? '',
|
||||
}),
|
||||
);
|
||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||
const result = await this.sms.send({
|
||||
organizationId: actor.organizationId,
|
||||
@@ -712,47 +945,50 @@ export class PeopleService implements OnModuleInit {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async sendSms(actor: Actor, personId: string, text: string) {
|
||||
const body = text.trim();
|
||||
if (!body || body.length > 500) {
|
||||
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
||||
/** نام سازمان + ماندههای کششدهٔ تهحساب برای توکنهای پیامک */
|
||||
private async personSmsVars(
|
||||
organizationId: string,
|
||||
personId: string,
|
||||
base: {
|
||||
name: string;
|
||||
phone: string;
|
||||
title: string;
|
||||
company: string;
|
||||
},
|
||||
) {
|
||||
const [org, link] = await Promise.all([
|
||||
this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { name: true },
|
||||
}),
|
||||
this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId, connection: { organizationId } },
|
||||
orderBy: [
|
||||
{ connection: { isDefault: 'desc' } },
|
||||
{ linkedAt: 'desc' },
|
||||
],
|
||||
include: { balanceCache: true },
|
||||
}),
|
||||
]);
|
||||
let rialBalance = '';
|
||||
let metalBalance = '';
|
||||
const cache = link?.balanceCache;
|
||||
if (cache) {
|
||||
const rawView = extractMandehRaw(cache.rawResponse);
|
||||
const bal = smsBalanceTexts({
|
||||
mandehFields: rawView.fields,
|
||||
rialBalance: cache.rialBalance,
|
||||
goldBalance: cache.goldBalance,
|
||||
});
|
||||
rialBalance = bal.rialBalance;
|
||||
metalBalance = bal.metalBalance;
|
||||
}
|
||||
const person = await this.get(actor, personId);
|
||||
const mobile = person.mobile || person.phone;
|
||||
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
||||
const message = renderSmsText(body, {
|
||||
name: person.fullName,
|
||||
phone: mobile,
|
||||
title: person.fullName,
|
||||
company: person.company?.name ?? '',
|
||||
});
|
||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||
const result = await this.sms.send({
|
||||
organizationId: actor.organizationId,
|
||||
phone: mobile,
|
||||
text: message,
|
||||
source: 'manual',
|
||||
});
|
||||
await this.outboundSms.record({
|
||||
organizationId: actor.organizationId,
|
||||
personId: person.id,
|
||||
mobile,
|
||||
body: message,
|
||||
source: 'manual',
|
||||
status: result.ok ? 'sent' : 'failed',
|
||||
error: result.ok ? null : result.reason,
|
||||
actorUserId: actor.userId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
result.blocked
|
||||
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
||||
: result.reason === 'SMS not configured'
|
||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
return {
|
||||
...base,
|
||||
from: org?.name?.trim() || '',
|
||||
rialBalance,
|
||||
metalBalance,
|
||||
};
|
||||
}
|
||||
|
||||
listSmsHistory(actor: Actor, personId: string, limit?: number) {
|
||||
@@ -776,6 +1012,8 @@ export class PeopleService implements OnModuleInit {
|
||||
previousImportSource: row.previousImportSource,
|
||||
previousSourceRef: row.previousSourceRef,
|
||||
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
|
||||
showGenderInName: row.showGenderInName,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
mobile: row.mobile,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { mergeSameMobilePeople } from './merge-same-mobile';
|
||||
|
||||
const FIX_KEY = 'person-mobile-canonical-v3';
|
||||
|
||||
@Injectable()
|
||||
export class SameMobileMergeService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SameMobileMergeService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
void this.run();
|
||||
}
|
||||
|
||||
private async run() {
|
||||
try {
|
||||
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
|
||||
SELECT 1 AS ok
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND lower(table_name) = 'person'
|
||||
AND lower(column_name) = 'gender'
|
||||
`;
|
||||
if (!ready.length) {
|
||||
this.logger.warn('یکسانسازی موبایل رد شد: ستون gender هنوز نیست');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw`
|
||||
CREATE TABLE IF NOT EXISTS "DataFix" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
const done = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (done.length) return;
|
||||
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
|
||||
try {
|
||||
const again = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (again.length) return;
|
||||
|
||||
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
|
||||
merged += result.merged;
|
||||
rewritten += result.rewritten;
|
||||
failed += result.failed;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
this.logger.error(
|
||||
`یکسانسازی موبایل برای محیط ${org.id} انجام نشد`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failed) return;
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
|
||||
ON CONFLICT ("key") DO NOTHING
|
||||
`;
|
||||
this.logger.log(`شمارههای تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
|
||||
} finally {
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'یکسانسازی موبایل مشتریها انجام نشد',
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import {
|
||||
ConversationFileBytesDto,
|
||||
CreateConversationDmDto,
|
||||
CreateConversationGroupDto,
|
||||
CreateConversationChannelDto,
|
||||
@@ -19,12 +20,34 @@ import {
|
||||
ToggleMessageReactionDto,
|
||||
} from './dto';
|
||||
import { ConversationsService } from './conversations.service';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
|
||||
@Controller('conversations')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('conversations')
|
||||
export class ConversationsController {
|
||||
constructor(private readonly conversations: ConversationsService) {}
|
||||
constructor(
|
||||
private readonly conversations: ConversationsService,
|
||||
private readonly storage: StorageQuotaService,
|
||||
) {}
|
||||
|
||||
@Post('files/reserve')
|
||||
reserveFile(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: ConversationFileBytesDto,
|
||||
) {
|
||||
return this.storage.assertCanAdd(user.organizationId, dto.bytes).then(() =>
|
||||
this.storage.usageForOrganization(user.organizationId),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('files/commit')
|
||||
commitFile(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: ConversationFileBytesDto,
|
||||
) {
|
||||
return this.storage.addChat(user.organizationId, dto.bytes);
|
||||
}
|
||||
|
||||
@Get('access')
|
||||
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
||||
|
||||
@@ -656,7 +656,7 @@ export class ConversationsService {
|
||||
});
|
||||
if (!row) {
|
||||
throw new BadRequestException(
|
||||
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیطها → تنظیمات گفتگو پیکربندی کنید.',
|
||||
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ArrayMinSize,
|
||||
ArrayMaxSize,
|
||||
@@ -32,6 +36,14 @@ export class UpdateConversationSettingsDto {
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export class ConversationFileBytesDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100 * 1024 * 1024)
|
||||
bytes!: number;
|
||||
}
|
||||
|
||||
export class CreateConversationDmDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum CustomFieldEntityDto {
|
||||
person = 'person',
|
||||
company = 'company',
|
||||
deal = 'deal',
|
||||
note = 'note',
|
||||
}
|
||||
|
||||
export enum CustomFieldTypeDto {
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
|
||||
@IsOptional()
|
||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||
color?: CustomerGroupColor;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
class UpdateCustomerGroupDto {
|
||||
@@ -38,6 +50,16 @@ class UpdateCustomerGroupDto {
|
||||
@IsOptional()
|
||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||
color?: CustomerGroupColor;
|
||||
|
||||
/** null گروه را اصلی میکند */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
@Controller('customer-groups')
|
||||
|
||||
@@ -48,24 +48,24 @@ export class CustomerGroupsService {
|
||||
const rows = await this.prisma.customerGroup.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||
include: { _count: { select: { people: true, companies: true } } },
|
||||
include: {
|
||||
_count: { select: { people: true, companies: true } },
|
||||
parent: { select: { name: true } },
|
||||
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
fromTahesab: Boolean(r.tahesabGid),
|
||||
personCount: r._count.people,
|
||||
companyCount: r._count.companies,
|
||||
}));
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: { name: string; color?: CustomerGroupColor },
|
||||
dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
|
||||
): Promise<CustomerGroupDto> {
|
||||
const name = this.cleanName(dto.name);
|
||||
const assigneeIds =
|
||||
dto.assigneeIds !== undefined
|
||||
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
|
||||
: [];
|
||||
const max = await this.prisma.customerGroup.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
@@ -77,6 +77,9 @@ export class CustomerGroupsService {
|
||||
name,
|
||||
color: this.cleanColor(dto.color),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
...(assigneeIds.length
|
||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -88,15 +91,33 @@ export class CustomerGroupsService {
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string; color?: CustomerGroupColor },
|
||||
dto: {
|
||||
name?: string;
|
||||
color?: CustomerGroupColor;
|
||||
parentId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
},
|
||||
): Promise<CustomerGroupDto> {
|
||||
await this.require(organizationId, id);
|
||||
const parentId =
|
||||
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
|
||||
if (parentId !== undefined) {
|
||||
await this.assertParent(organizationId, id, parentId);
|
||||
}
|
||||
const assigneeIds =
|
||||
dto.assigneeIds !== undefined
|
||||
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
|
||||
: undefined;
|
||||
try {
|
||||
await this.prisma.customerGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
...(assigneeIds !== undefined
|
||||
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -105,6 +126,32 @@ export class CustomerGroupsService {
|
||||
return (await this.list(organizationId)).find((g) => g.id === id)!;
|
||||
}
|
||||
|
||||
/** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */
|
||||
private async assertParent(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
) {
|
||||
if (parentId == null) return;
|
||||
if (parentId === id) {
|
||||
throw new BadRequestException('گروه نمیتواند زیرمجموعهٔ خودش باشد.');
|
||||
}
|
||||
const parent = await this.prisma.customerGroup.findFirst({
|
||||
where: { id: parentId, organizationId },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.');
|
||||
if (parent.parentId) {
|
||||
throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.');
|
||||
}
|
||||
const childCount = await this.prisma.customerGroup.count({
|
||||
where: { organizationId, parentId: id },
|
||||
});
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException('این گروه خودش زیرگروه دارد و نمیتواند فرعی شود.');
|
||||
}
|
||||
}
|
||||
|
||||
/** مشتریها حذف نمیشوند؛ فقط این گروه از رویشان برداشته میشود */
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.require(organizationId, id);
|
||||
@@ -280,6 +327,48 @@ export class CustomerGroupsService {
|
||||
return link?.group.name ?? null;
|
||||
}
|
||||
|
||||
private toDto(r: {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
position: number;
|
||||
tahesabGid: string | null;
|
||||
parentId: string | null;
|
||||
parent?: { name: string } | null;
|
||||
_count: { people: number; companies: number };
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
}): CustomerGroupDto {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
fromTahesab: Boolean(r.tahesabGid),
|
||||
personCount: r._count.people,
|
||||
companyCount: r._count.companies,
|
||||
parentId: r.parentId,
|
||||
parentName: r.parent?.name ?? null,
|
||||
assignees: r.assignees.map((a) => ({ id: a.id, fullName: a.fullName })),
|
||||
};
|
||||
}
|
||||
|
||||
private async validAssigneeIds(organizationId: string, ids: string[]): Promise<string[]> {
|
||||
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
|
||||
if (!unique.length) return [];
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: unique },
|
||||
isActive: true,
|
||||
memberships: { some: { organizationId, isActive: true } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (rows.length !== unique.length) {
|
||||
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
||||
}
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
private async require(organizationId: string, id: string) {
|
||||
const row = await this.prisma.customerGroup.findFirst({
|
||||
where: { id, organizationId },
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { type Actor, elevated } from '../common/record-scope';
|
||||
import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates';
|
||||
import { internalRoutineTaskWhere } from '../routines/internal-routine';
|
||||
import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine';
|
||||
import { GamificationService, parsePeriod, toRuleDto } from './gamification.service';
|
||||
import type {
|
||||
@@ -751,6 +752,7 @@ export class GamificationAdminService {
|
||||
organizationId,
|
||||
status: { not: 'cancelled' },
|
||||
scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) },
|
||||
...internalRoutineTaskWhere,
|
||||
};
|
||||
const [total, done] = await Promise.all([
|
||||
this.prisma.routineTask.count({ where }),
|
||||
|
||||
@@ -17,7 +17,11 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { ownerWhere, type Actor } from '../../common/record-scope';
|
||||
import { MontifyConnectionsService } from './connections.service';
|
||||
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
|
||||
}
|
||||
|
||||
function mobileVariants(mobile: string): string[] {
|
||||
const digits = mobile.replace(/\D/g, '');
|
||||
let core = digits;
|
||||
if (core.startsWith('0098')) core = core.slice(4);
|
||||
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
|
||||
else if (core.startsWith('0')) core = core.slice(1);
|
||||
if (!core) return [mobile];
|
||||
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
|
||||
return phoneIdentityKeys(mobile);
|
||||
}
|
||||
|
||||
function formatAddress(a: MontifyAddress): string | null {
|
||||
@@ -417,7 +415,7 @@ export class MontifySyncService {
|
||||
}> {
|
||||
const externalId = String(c.id);
|
||||
const { firstName, lastName } = splitName(c);
|
||||
const mobile = normalizePhone(c.phone ?? null);
|
||||
const mobile = storedPhoneNumber(c.phone ?? null);
|
||||
const email = normalizeEmail(c.email ?? null);
|
||||
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
|
||||
const profile: CustomerProfile = {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import {
|
||||
pickAddress,
|
||||
pickCity,
|
||||
pickNationalId,
|
||||
pickRasteh,
|
||||
tahesabProfilePatch,
|
||||
} from '../moshtari-profile';
|
||||
import {
|
||||
assertFactorCode,
|
||||
buildFactorCode,
|
||||
@@ -116,12 +123,52 @@ function testSanadBuilders() {
|
||||
assert(bank[12] === fc, 'bank factor index');
|
||||
}
|
||||
|
||||
function testMoshtariProfile() {
|
||||
const row = {
|
||||
CodeMelli: '00112312311',
|
||||
Address: 'خیابان ولیعصر',
|
||||
City: 'تهران',
|
||||
Rasteh: 2,
|
||||
BDate: null,
|
||||
};
|
||||
assert(pickNationalId(row) === '00112312311', 'national id');
|
||||
assert(pickAddress(row) === 'خیابان ولیعصر', 'address');
|
||||
assert(pickCity(row) === 'تهران', 'city');
|
||||
assert(pickRasteh(row) === 2, 'rasteh');
|
||||
assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national');
|
||||
|
||||
const patch = tahesabProfilePatch(row, {
|
||||
birthDate: null,
|
||||
addresses: [],
|
||||
customFields: { keep: true },
|
||||
});
|
||||
assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city');
|
||||
const fields = patch.customFields as Record<string, unknown>;
|
||||
assert(fields.nationalId === '00112312311', 'cf national');
|
||||
assert(fields.city === 'تهران', 'cf city');
|
||||
assert(fields.rasteh === 2, 'cf rasteh');
|
||||
assert(fields.keep === true, 'cf merge');
|
||||
|
||||
const emptyKeep = tahesabProfilePatch(
|
||||
{ CodeMelli: '', Address: '', City: '', Rasteh: '' },
|
||||
{
|
||||
birthDate: new Date('2000-01-01'),
|
||||
addresses: ['قدیمی'],
|
||||
customFields: { nationalId: '111' },
|
||||
},
|
||||
);
|
||||
assert(emptyKeep.addresses == null, 'empty does not wipe address');
|
||||
assert(emptyKeep.customFields == null, 'empty does not wipe custom');
|
||||
assert(emptyKeep.birthDate == null, 'existing birth kept');
|
||||
}
|
||||
|
||||
function main() {
|
||||
testCryptoRoundtrip();
|
||||
testFactorCode();
|
||||
testMobile();
|
||||
testMandehNormalize();
|
||||
testSanadBuilders();
|
||||
testMoshtariProfile();
|
||||
console.log('tahesab unit checks: OK');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,44 @@ import {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
CustomerGroupSummaryDto,
|
||||
SalesExpertOverviewDto,
|
||||
SalesFactorOverviewDto,
|
||||
SalesGroupOverviewDto,
|
||||
SalesInternetOverviewDto,
|
||||
SalesNamedMovementDto,
|
||||
TahesabAsnadKind,
|
||||
TahesabAsnadLineDto,
|
||||
TahesabAsnadReportPageDto,
|
||||
TahesabAsnadSummaryDto,
|
||||
TahesabAsnadSyncJobDto,
|
||||
TahesabSalesOverviewDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
TAHESAB_ASNAD_KIND_LABELS,
|
||||
TAHESAB_JENS_FELEZ_LABELS,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { toGregorian } from '../../common/jalali';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import {
|
||||
SALES_FACTOR_KEYS,
|
||||
SALES_FACTOR_LABELS,
|
||||
classifyDirection,
|
||||
classifyFactor,
|
||||
combinedPercent,
|
||||
movementStats,
|
||||
positiveAmount,
|
||||
type SalesDoc,
|
||||
} from './sales-factors';
|
||||
|
||||
const ASNAD_COUNT_LAST = 200;
|
||||
const ASNAD_COUNT_LAST = 5000;
|
||||
const SYNC_CONCURRENCY = 3;
|
||||
|
||||
function latinDigits(value: string): string {
|
||||
return value
|
||||
.replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch)))
|
||||
.replace(/[٠-٩]/g, (ch) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(ch)));
|
||||
}
|
||||
const ASNAD_KINDS = new Set<string>(['sale', 'return', 'purchase', 'other']);
|
||||
|
||||
function normalizeShamsiYmd(input: string): string {
|
||||
@@ -167,6 +190,7 @@ export class TahesabAsnadReportService {
|
||||
connectionId: string | undefined,
|
||||
from: string,
|
||||
to: string,
|
||||
scope?: { personId?: string; groupIds?: string[] },
|
||||
): Promise<TahesabAsnadSyncJobDto> {
|
||||
const fromNorm = normalizeShamsiYmd(from);
|
||||
const toNorm = normalizeShamsiYmd(to);
|
||||
@@ -177,6 +201,22 @@ export class TahesabAsnadReportService {
|
||||
organizationId,
|
||||
connectionId,
|
||||
);
|
||||
const scopedPersonIds = await this.resolveSyncPersonIds(organizationId, scope);
|
||||
if (scopedPersonIds && scopedPersonIds.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'برای گروه یا شخص انتخابشده مشتریای پیدا نشد.',
|
||||
);
|
||||
}
|
||||
if (scopedPersonIds) {
|
||||
const linked = await this.prisma.personAccountingLink.count({
|
||||
where: { connectionId: resolvedId, personId: { in: scopedPersonIds } },
|
||||
});
|
||||
if (linked === 0) {
|
||||
throw new BadRequestException(
|
||||
'مشتریان این فیلتر به تهحساب وصل نیستند. بهروزرسانی همه را بزنید یا اول اتصال مشتری را انجام دهید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const job = await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
@@ -184,7 +224,13 @@ export class TahesabAsnadReportService {
|
||||
connectionId: resolvedId,
|
||||
kind: 'asnad-sync',
|
||||
status: 'pending',
|
||||
request: { from: fromNorm, to: toNorm, connectionId: resolvedId },
|
||||
request: {
|
||||
from: fromNorm,
|
||||
to: toNorm,
|
||||
connectionId: resolvedId,
|
||||
personId: scope?.personId ?? null,
|
||||
groupIds: scope?.groupIds ?? [],
|
||||
},
|
||||
result: {
|
||||
connectionId: resolvedId,
|
||||
from: fromNorm,
|
||||
@@ -197,9 +243,14 @@ export class TahesabAsnadReportService {
|
||||
},
|
||||
});
|
||||
|
||||
void this.runSync(job.id, organizationId, resolvedId, fromNorm, toNorm).catch(
|
||||
() => undefined,
|
||||
);
|
||||
void this.runSync(
|
||||
job.id,
|
||||
organizationId,
|
||||
resolvedId,
|
||||
fromNorm,
|
||||
toNorm,
|
||||
scopedPersonIds,
|
||||
).catch(() => undefined);
|
||||
|
||||
return this.mapJob(job);
|
||||
}
|
||||
@@ -221,6 +272,7 @@ export class TahesabAsnadReportService {
|
||||
connectionId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
personIds: string[] | null,
|
||||
) {
|
||||
const progress: SyncProgress = {
|
||||
connectionId,
|
||||
@@ -240,7 +292,10 @@ export class TahesabAsnadReportService {
|
||||
|
||||
const { api } = await this.connections.getApi(organizationId, connectionId);
|
||||
const links = await this.prisma.personAccountingLink.findMany({
|
||||
where: { connectionId },
|
||||
where: {
|
||||
connectionId,
|
||||
...(personIds ? { personId: { in: personIds } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
personId: true,
|
||||
@@ -259,6 +314,7 @@ export class TahesabAsnadReportService {
|
||||
organizationId,
|
||||
connectionId,
|
||||
tarikh: { gte: from, lte: to },
|
||||
...(personIds ? { personId: { in: personIds } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -307,6 +363,11 @@ export class TahesabAsnadReportService {
|
||||
mali: pickNum(item.Mali),
|
||||
count: pickNum(item.Count),
|
||||
ayar: pickNum(item.Ayar),
|
||||
darsad: pickNum(item.Darsad ?? item.darsad),
|
||||
darsadTalayee: pickNum(
|
||||
item.Darsad_Talayee ?? item.DarsadTalayee ?? item.darsadTalayee,
|
||||
),
|
||||
maliatDarsad: pickNum(item.MaliatDarsad ?? item.Maliat_Darsad),
|
||||
mazaneh: pickNum(item.Mazaneh),
|
||||
fi: pickNum(item.Fi),
|
||||
sharh1: pickStr(item.Sharh1),
|
||||
@@ -383,11 +444,12 @@ export class TahesabAsnadReportService {
|
||||
where.jensFelez = params.jensFelez;
|
||||
}
|
||||
if (params.groupIds?.length) {
|
||||
const groupIds = await this.expandGroupIds(params.organizationId, params.groupIds);
|
||||
where.personId = {
|
||||
in: (
|
||||
await this.prisma.personGroupLink.findMany({
|
||||
where: {
|
||||
groupId: { in: params.groupIds },
|
||||
groupId: { in: groupIds },
|
||||
person: { organizationId: params.organizationId },
|
||||
},
|
||||
select: { personId: true },
|
||||
@@ -614,4 +676,553 @@ export class TahesabAsnadReportService {
|
||||
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** null یعنی همهٔ مشتریان متصل. آرایه یعنی فقط همین اشخاص. */
|
||||
private async resolveSyncPersonIds(
|
||||
organizationId: string,
|
||||
scope?: { personId?: string; groupIds?: string[] },
|
||||
): Promise<string[] | null> {
|
||||
const groupIds = (scope?.groupIds ?? []).map((id) => id.trim()).filter(Boolean);
|
||||
const personId = scope?.personId?.trim() || undefined;
|
||||
if (!personId && groupIds.length === 0) return null;
|
||||
let ids: string[] | null = null;
|
||||
if (groupIds.length) {
|
||||
const expanded = await this.expandGroupIds(organizationId, groupIds);
|
||||
const rows = await this.prisma.personGroupLink.findMany({
|
||||
where: {
|
||||
groupId: { in: expanded },
|
||||
person: { organizationId },
|
||||
},
|
||||
select: { personId: true },
|
||||
distinct: ['personId'],
|
||||
});
|
||||
ids = rows.map((row) => row.personId);
|
||||
}
|
||||
if (personId) ids = ids ? ids.filter((id) => id === personId) : [personId];
|
||||
return ids ?? [];
|
||||
}
|
||||
|
||||
async searchCustomers(
|
||||
organizationId: string,
|
||||
connectionId: string | undefined,
|
||||
q: string,
|
||||
) {
|
||||
const resolvedId = await this.connections.resolveConnectionId(
|
||||
organizationId,
|
||||
connectionId,
|
||||
);
|
||||
const query = q.trim();
|
||||
const tokens = query.split(/\s+/).filter(Boolean);
|
||||
const code = Number(latinDigits(query));
|
||||
const or: Prisma.PersonAccountingLinkWhereInput[] = [];
|
||||
if (tokens.length) {
|
||||
or.push({
|
||||
person: {
|
||||
organizationId,
|
||||
AND: tokens.map((token) => ({
|
||||
OR: [
|
||||
{ firstName: { contains: token } },
|
||||
{ lastName: { contains: token } },
|
||||
],
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (Number.isInteger(code) && code > 0 && latinDigits(query) === String(code)) {
|
||||
or.push({ moshtariCode: code, person: { organizationId } });
|
||||
}
|
||||
const rows = await this.prisma.personAccountingLink.findMany({
|
||||
where: {
|
||||
connectionId: resolvedId,
|
||||
...(or.length ? { OR: or } : { person: { organizationId } }),
|
||||
},
|
||||
take: 30,
|
||||
orderBy: [{ person: { lastName: 'asc' } }, { person: { firstName: 'asc' } }],
|
||||
select: {
|
||||
personId: true,
|
||||
moshtariCode: true,
|
||||
person: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
personId: row.personId,
|
||||
moshtariCode: row.moshtariCode,
|
||||
name: `${row.person.firstName} ${row.person.lastName}`.trim(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** گروه اصلی، زیرگروهها را هم داخل فیلتر میآورد. */
|
||||
private async expandGroupIds(organizationId: string, ids: string[]): Promise<string[]> {
|
||||
const rows = await this.prisma.customerGroup.findMany({
|
||||
where: { organizationId },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
const children = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.parentId) continue;
|
||||
const list = children.get(row.parentId) ?? [];
|
||||
list.push(row.id);
|
||||
children.set(row.parentId, list);
|
||||
}
|
||||
const out = new Set(ids);
|
||||
const walk = (id: string) => {
|
||||
for (const child of children.get(id) ?? []) {
|
||||
if (out.has(child)) continue;
|
||||
out.add(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
for (const id of ids) walk(id);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
async overview(
|
||||
organizationId: string,
|
||||
opts: {
|
||||
connectionId?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
groupIds?: string[];
|
||||
personId?: string;
|
||||
jensFelez?: number;
|
||||
},
|
||||
): Promise<TahesabSalesOverviewDto> {
|
||||
const where = await this.buildWhere({ organizationId, ...opts });
|
||||
const rows = await this.prisma.accountingAsnadLine.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
tarikh: true,
|
||||
noRaw: true,
|
||||
noKar: true,
|
||||
jensFelez: true,
|
||||
vazn: true,
|
||||
tabdilVazn: true,
|
||||
mali: true,
|
||||
count: true,
|
||||
darsad: true,
|
||||
darsadTalayee: true,
|
||||
maliatDarsad: true,
|
||||
factorCode: true,
|
||||
personId: true,
|
||||
person: {
|
||||
select: {
|
||||
ownerId: true,
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
groupLinks: {
|
||||
include: {
|
||||
group: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parentId: true,
|
||||
parent: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const docs: SalesDoc[] = rows.map((row) => ({
|
||||
bucketKey: (row.noKar ?? '').trim() || 'بدون نام',
|
||||
factor: classifyFactor(row.noRaw, row.noKar),
|
||||
direction: classifyDirection(row.noRaw),
|
||||
weight: positiveAmount(row.vazn, row.tabdilVazn),
|
||||
mali: Math.abs(row.mali ?? 0),
|
||||
count: Math.abs(row.count ?? 0),
|
||||
percent: combinedPercent(row.darsad, row.darsadTalayee, row.maliatDarsad),
|
||||
tarikh: row.tarikh,
|
||||
}));
|
||||
|
||||
const factors: SalesFactorOverviewDto[] = SALES_FACTOR_KEYS.map((key) => {
|
||||
const slice = docs.filter((d) => d.factor === key);
|
||||
return {
|
||||
key,
|
||||
label: SALES_FACTOR_LABELS[key],
|
||||
sale: movementStats(slice, 'out'),
|
||||
purchase: movementStats(slice, 'in'),
|
||||
};
|
||||
});
|
||||
|
||||
const names = [...new Set(docs.filter((d) => d.factor === 'karsakhte').map((d) => d.bucketKey))];
|
||||
names.sort((a, b) => a.localeCompare(b, 'fa'));
|
||||
const karsakhte: SalesNamedMovementDto[] = names.map((name) => {
|
||||
const slice = docs.filter((d) => d.factor === 'karsakhte' && d.bucketKey === name);
|
||||
return {
|
||||
name,
|
||||
sale: movementStats(slice, 'out'),
|
||||
purchase: movementStats(slice, 'in'),
|
||||
};
|
||||
});
|
||||
|
||||
const groups = this.groupOverview(rows, docs);
|
||||
const experts = await this.expertOverview(organizationId, rows, docs);
|
||||
const internetPack = await this.internetOverview(
|
||||
organizationId,
|
||||
opts.from,
|
||||
opts.to,
|
||||
rows,
|
||||
opts.personId,
|
||||
opts.groupIds,
|
||||
);
|
||||
await this.addInternetCommission(organizationId, experts, internetPack.owners);
|
||||
const internet = internetPack.public;
|
||||
|
||||
const nos = new Set<string>();
|
||||
const otherNos = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.noRaw) continue;
|
||||
nos.add(row.noRaw);
|
||||
if (classifyFactor(row.noRaw, row.noKar) === 'other') otherNos.add(row.noRaw);
|
||||
}
|
||||
|
||||
return {
|
||||
factors,
|
||||
karsakhte,
|
||||
groups,
|
||||
experts,
|
||||
internet,
|
||||
distinctNos: [...nos].sort((a, b) => a.localeCompare(b, 'fa')),
|
||||
otherNos: [...otherNos].sort((a, b) => a.localeCompare(b, 'fa')),
|
||||
percentReady: rows.some((r) => r.darsad != null || r.darsadTalayee != null || r.maliatDarsad != null),
|
||||
};
|
||||
}
|
||||
|
||||
private groupOverview(
|
||||
rows: Array<{
|
||||
id: string;
|
||||
person: {
|
||||
groupLinks: Array<{
|
||||
group: { id: string; name: string; parentId: string | null; parent: { id: string; name: string } | null };
|
||||
}>;
|
||||
} | null;
|
||||
}>,
|
||||
docs: SalesDoc[],
|
||||
): SalesGroupOverviewDto[] {
|
||||
const byId = new Map(docs.map((d, i) => [rows[i]!.id, d]));
|
||||
type Acc = { label: string; parentId: string | null; parentName: string | null; gross: number; ret: number; mali: number; retMali: number };
|
||||
const acc = new Map<string, Acc>();
|
||||
rows.forEach((row, index) => {
|
||||
const doc = byId.get(row.id) ?? docs[index];
|
||||
if (!doc || (doc.direction !== 'out' && doc.direction !== 'sale_return')) return;
|
||||
const links = row.person?.groupLinks ?? [];
|
||||
const targets = links.length
|
||||
? links
|
||||
: [{ group: { id: '__none__', name: 'بدون گروه', parentId: null, parent: null } }];
|
||||
for (const link of targets) {
|
||||
const g = link.group;
|
||||
const bag = acc.get(g.id) ?? {
|
||||
label: g.name,
|
||||
parentId: g.parentId,
|
||||
parentName: g.parent?.name ?? null,
|
||||
gross: 0,
|
||||
ret: 0,
|
||||
mali: 0,
|
||||
retMali: 0,
|
||||
};
|
||||
if (doc.direction === 'out') {
|
||||
bag.gross += doc.weight;
|
||||
bag.mali += doc.mali;
|
||||
} else {
|
||||
bag.ret += doc.weight;
|
||||
bag.retMali += doc.mali;
|
||||
}
|
||||
acc.set(g.id, bag);
|
||||
}
|
||||
});
|
||||
|
||||
const parents = new Map<string, SalesGroupOverviewDto>();
|
||||
const ensure = (id: string, label: string) => {
|
||||
const found = parents.get(id);
|
||||
if (found) return found;
|
||||
const created: SalesGroupOverviewDto = {
|
||||
id,
|
||||
label,
|
||||
netSaleWeight: 0,
|
||||
netSaleMali: 0,
|
||||
saleReturnWeight: 0,
|
||||
children: [],
|
||||
};
|
||||
parents.set(id, created);
|
||||
return created;
|
||||
};
|
||||
for (const [id, bag] of acc) {
|
||||
const netW = Math.max(0, bag.gross - bag.ret);
|
||||
const netM = Math.max(0, bag.mali - bag.retMali);
|
||||
if (bag.parentId) {
|
||||
const parent = ensure(bag.parentId, bag.parentName || 'گروه اصلی');
|
||||
parent.netSaleWeight += netW;
|
||||
parent.netSaleMali += netM;
|
||||
parent.saleReturnWeight += bag.ret;
|
||||
parent.children.push({
|
||||
id,
|
||||
label: bag.label,
|
||||
netSaleWeight: netW,
|
||||
netSaleMali: netM,
|
||||
saleReturnWeight: bag.ret,
|
||||
});
|
||||
} else {
|
||||
const parent = ensure(id, bag.label);
|
||||
parent.netSaleWeight += netW;
|
||||
parent.netSaleMali += netM;
|
||||
parent.saleReturnWeight += bag.ret;
|
||||
}
|
||||
}
|
||||
return [...parents.values()].sort((a, b) => b.netSaleWeight - a.netSaleWeight);
|
||||
}
|
||||
|
||||
private async expertOverview(
|
||||
organizationId: string,
|
||||
rows: Array<{
|
||||
id: string;
|
||||
jensFelez: number | null;
|
||||
person: { ownerId: string | null; owner: { id: string; fullName: string } | null } | null;
|
||||
}>,
|
||||
docs: SalesDoc[],
|
||||
): Promise<SalesExpertOverviewDto[]> {
|
||||
type Bag = { name: string; silverW: number; silverM: number; silverRetW: number; silverRetM: number; investW: number; investM: number; investRetW: number; investRetM: number };
|
||||
const bags = new Map<string, Bag>();
|
||||
const touch = (id: string, name: string) => {
|
||||
const bag = bags.get(id) ?? {
|
||||
name,
|
||||
silverW: 0,
|
||||
silverM: 0,
|
||||
silverRetW: 0,
|
||||
silverRetM: 0,
|
||||
investW: 0,
|
||||
investM: 0,
|
||||
investRetW: 0,
|
||||
investRetM: 0,
|
||||
};
|
||||
bags.set(id, bag);
|
||||
return bag;
|
||||
};
|
||||
rows.forEach((row, index) => {
|
||||
const doc = docs[index];
|
||||
if (!doc || (doc.direction !== 'out' && doc.direction !== 'sale_return')) return;
|
||||
const ownerId = row.person?.owner?.id ?? row.person?.ownerId ?? '__none__';
|
||||
const name = row.person?.owner?.fullName || 'بدون کارشناس';
|
||||
const bag = touch(ownerId, name);
|
||||
const isReturn = doc.direction === 'sale_return';
|
||||
const silver = doc.factor === 'karsakhte' && row.jensFelez === 2;
|
||||
const invest = doc.factor === 'etiket_jewel';
|
||||
if (silver) {
|
||||
if (isReturn) {
|
||||
bag.silverRetW += doc.weight;
|
||||
bag.silverRetM += doc.mali;
|
||||
} else {
|
||||
bag.silverW += doc.weight;
|
||||
bag.silverM += doc.mali;
|
||||
}
|
||||
}
|
||||
if (invest) {
|
||||
if (isReturn) {
|
||||
bag.investRetW += doc.weight;
|
||||
bag.investRetM += doc.mali;
|
||||
} else {
|
||||
bag.investW += doc.weight;
|
||||
bag.investM += doc.mali;
|
||||
}
|
||||
}
|
||||
});
|
||||
const ownerIds = [...bags.keys()].filter((id) => id !== '__none__');
|
||||
const memberships = ownerIds.length
|
||||
? await this.prisma.membership.findMany({
|
||||
where: { organizationId, userId: { in: ownerIds } },
|
||||
select: { userId: true, commissionPercent: true },
|
||||
})
|
||||
: [];
|
||||
const percentOf = new Map(memberships.map((m) => [m.userId, Number(m.commissionPercent ?? 0)]));
|
||||
return [...bags.entries()]
|
||||
.map(([userId, bag]) => {
|
||||
const percent = percentOf.get(userId) ?? 0;
|
||||
const silverMali = Math.max(0, bag.silverM - bag.silverRetM);
|
||||
const investMali = Math.max(0, bag.investM - bag.investRetM);
|
||||
return {
|
||||
userId,
|
||||
name: bag.name,
|
||||
percent,
|
||||
silverWeight: Math.max(0, bag.silverW - bag.silverRetW),
|
||||
silverMali,
|
||||
silverCommission: (silverMali * percent) / 100,
|
||||
investWeight: Math.max(0, bag.investW - bag.investRetW),
|
||||
investMali,
|
||||
investCommission: (investMali * percent) / 100,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.silverMali + b.investMali - (a.silverMali + a.investMali));
|
||||
}
|
||||
|
||||
private async addInternetCommission(
|
||||
organizationId: string,
|
||||
experts: SalesExpertOverviewDto[],
|
||||
owners: Map<string, { name: string; weight: number; mali: number }>,
|
||||
) {
|
||||
const missing = [...owners.keys()].filter((id) => id !== '__none__' && !experts.some((e) => e.userId === id));
|
||||
const memberships = missing.length
|
||||
? await this.prisma.membership.findMany({
|
||||
where: { organizationId, userId: { in: missing } },
|
||||
select: { userId: true, commissionPercent: true },
|
||||
})
|
||||
: [];
|
||||
const percentOf = new Map(memberships.map((m) => [m.userId, Number(m.commissionPercent ?? 0)]));
|
||||
for (const [userId, extra] of owners) {
|
||||
let row = experts.find((e) => e.userId === userId);
|
||||
if (!row) {
|
||||
row = {
|
||||
userId,
|
||||
name: extra.name,
|
||||
percent: percentOf.get(userId) ?? 0,
|
||||
silverWeight: 0,
|
||||
silverMali: 0,
|
||||
silverCommission: 0,
|
||||
investWeight: 0,
|
||||
investMali: 0,
|
||||
investCommission: 0,
|
||||
};
|
||||
experts.push(row);
|
||||
}
|
||||
row.investWeight += extra.weight;
|
||||
row.investMali += extra.mali;
|
||||
row.investCommission = (row.investMali * row.percent) / 100;
|
||||
}
|
||||
experts.sort((a, b) => b.silverMali + b.investMali - (a.silverMali + a.investMali));
|
||||
const active = experts.filter(
|
||||
(e) => e.silverWeight > 0 || e.investWeight > 0 || e.silverMali > 0 || e.investMali > 0,
|
||||
);
|
||||
experts.splice(0, experts.length, ...active);
|
||||
}
|
||||
|
||||
/** null یعنی گروه و شخص اشتراکی ندارند و سفارشی نباید بیاید. */
|
||||
private async internetPersonWhere(
|
||||
organizationId: string,
|
||||
personId?: string,
|
||||
groupIds?: string[],
|
||||
): Promise<{ personId: string | { in: string[] } } | Record<string, never> | null> {
|
||||
if (!groupIds?.length) {
|
||||
return personId ? { personId } : {};
|
||||
}
|
||||
const expanded = await this.expandGroupIds(organizationId, groupIds);
|
||||
const links = await this.prisma.personGroupLink.findMany({
|
||||
where: {
|
||||
groupId: { in: expanded },
|
||||
person: { organizationId },
|
||||
},
|
||||
select: { personId: true },
|
||||
distinct: ['personId'],
|
||||
});
|
||||
let ids = links.map((link) => link.personId);
|
||||
if (personId) ids = ids.filter((id) => id === personId);
|
||||
if (!ids.length) return null;
|
||||
return { personId: { in: ids } };
|
||||
}
|
||||
|
||||
private async internetOverview(
|
||||
organizationId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
rows: Array<{ factorCode: string | null }>,
|
||||
personId?: string,
|
||||
groupIds?: string[],
|
||||
): Promise<{
|
||||
public: SalesInternetOverviewDto;
|
||||
owners: Map<string, { name: string; weight: number; mali: number }>;
|
||||
}> {
|
||||
const empty: SalesInternetOverviewDto = {
|
||||
orderCount: 0,
|
||||
returnCount: 0,
|
||||
netGold: 0,
|
||||
netSilver: 0,
|
||||
returnGold: 0,
|
||||
returnSilver: 0,
|
||||
netMali: 0,
|
||||
returnMali: 0,
|
||||
linkedSkipped: 0,
|
||||
};
|
||||
const start = shamsiStart(from);
|
||||
const end = shamsiEnd(to);
|
||||
const owners = new Map<string, { name: string; weight: number; mali: number }>();
|
||||
if (!start || !end) return { public: empty, owners };
|
||||
const personWhere = await this.internetPersonWhere(organizationId, personId, groupIds);
|
||||
if (!personWhere) return { public: empty, owners };
|
||||
const orders = await this.prisma.montifyOrder.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
deletedAt: null,
|
||||
...personWhere,
|
||||
externalCreatedAt: { gte: start, lte: end },
|
||||
},
|
||||
select: {
|
||||
externalId: true,
|
||||
orderReference: true,
|
||||
orderStatus: true,
|
||||
cancelledAt: true,
|
||||
totalPrice: true,
|
||||
discountTotalPrice: true,
|
||||
sumGold: true,
|
||||
sumSilver: true,
|
||||
personId: true,
|
||||
},
|
||||
});
|
||||
const personIds = [...new Set(orders.map((order) => order.personId).filter((id): id is string => Boolean(id)))];
|
||||
const people = personIds.length
|
||||
? await this.prisma.person.findMany({
|
||||
where: { organizationId, id: { in: personIds } },
|
||||
select: { id: true, ownerId: true, owner: { select: { id: true, fullName: true } } },
|
||||
})
|
||||
: [];
|
||||
const personById = new Map(people.map((person) => [person.id, person]));
|
||||
const linked = new Set(rows.map((r) => r.factorCode).filter((v): v is string => Boolean(v)));
|
||||
const result = { ...empty };
|
||||
for (const order of orders) {
|
||||
const ref = (order.orderReference || order.externalId || '').trim();
|
||||
if (ref && linked.has(ref)) {
|
||||
result.linkedSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
const cancelled =
|
||||
Boolean(order.cancelledAt) ||
|
||||
/cancel|لغو|مرجوع/i.test(order.orderStatus ?? '');
|
||||
const gold = Number(order.sumGold ?? 0);
|
||||
const silver = Number(order.sumSilver ?? 0);
|
||||
const mali = Number(order.discountTotalPrice ?? order.totalPrice ?? 0);
|
||||
if (cancelled) {
|
||||
result.returnCount += 1;
|
||||
result.returnGold += gold;
|
||||
result.returnSilver += silver;
|
||||
result.returnMali += mali;
|
||||
} else {
|
||||
result.orderCount += 1;
|
||||
result.netGold += gold;
|
||||
result.netSilver += silver;
|
||||
result.netMali += mali;
|
||||
const person = order.personId ? personById.get(order.personId) : undefined;
|
||||
const ownerId = person?.owner?.id ?? person?.ownerId ?? '__none__';
|
||||
const name = person?.owner?.fullName || 'بدون کارشناس';
|
||||
const bag = owners.get(ownerId) ?? { name, weight: 0, mali: 0 };
|
||||
bag.weight += gold + silver;
|
||||
bag.mali += mali;
|
||||
owners.set(ownerId, bag);
|
||||
}
|
||||
}
|
||||
return { public: result, owners };
|
||||
}
|
||||
}
|
||||
|
||||
function shamsiStart(ymd: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
|
||||
if (!m) return null;
|
||||
const g = toGregorian(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
return new Date(g.gy, g.gm - 1, g.gd, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function shamsiEnd(ymd: string): Date | null {
|
||||
const start = shamsiStart(ymd);
|
||||
if (!start) return null;
|
||||
return new Date(start.getFullYear(), start.getMonth(), start.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
customerSyncHours: number[];
|
||||
customerSyncLastRunAt: Date | null;
|
||||
customerSyncLastSummary: string | null;
|
||||
customerSyncLastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
|
||||
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
|
||||
customerSyncLastSummary: row.customerSyncLastSummary,
|
||||
customerSyncLastError: row.customerSyncLastError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
|
||||
|
||||
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
|
||||
|
||||
let customerSyncHours: number[] | undefined;
|
||||
if (dto.customerSyncHours !== undefined) {
|
||||
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
throw new BadRequestException('ساعتهای بروزرسانی نباید تکراری باشند.');
|
||||
}
|
||||
customerSyncHours = [...hours].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
let enc = {
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
|
||||
enabled: dto.enabled ?? row.enabled,
|
||||
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
|
||||
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(updated);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
|
||||
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یکبار در هر ساعت. */
|
||||
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '';
|
||||
let hour = Number(read('hour'));
|
||||
if (!Number.isFinite(hour) || hour === 24) hour = 0;
|
||||
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
|
||||
return { hour, slot };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabCustomerSyncScheduler {
|
||||
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly links: TahesabPersonLinkService,
|
||||
) {}
|
||||
|
||||
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async tick() {
|
||||
if (process.env.NODE_ENV === 'test' || this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
const { hour, slot } = tehranSyncSlot();
|
||||
const connections = await this.prisma.accountingConnection.findMany({
|
||||
where: {
|
||||
provider: 'tahesab',
|
||||
enabled: true,
|
||||
customerSyncHours: { has: hour },
|
||||
},
|
||||
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
|
||||
});
|
||||
for (const connection of connections) {
|
||||
if (connection.customerSyncLastSlot === slot) continue;
|
||||
const claimed = await this.prisma.accountingConnection.updateMany({
|
||||
where: {
|
||||
id: connection.id,
|
||||
OR: [
|
||||
{ customerSyncLastSlot: null },
|
||||
{ customerSyncLastSlot: { not: slot } },
|
||||
],
|
||||
},
|
||||
data: { customerSyncLastSlot: slot },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
await this.run(connection.organizationId, connection.id, connection.label);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`بررسی زمانبندی مشتریان تهحساب ناموفق بود: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(organizationId: string, connectionId: string, label: string) {
|
||||
try {
|
||||
const result = await this.links.syncAllCustomers(organizationId, connectionId);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: result.summary,
|
||||
customerSyncLastError: result.error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
|
||||
} catch (err) {
|
||||
const message = (err instanceof Error ? err.message : 'همگامسازی ناموفق بود').slice(0, 500);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: null,
|
||||
customerSyncLastError: message,
|
||||
},
|
||||
});
|
||||
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowInsecureTls?: boolean;
|
||||
|
||||
/** ساعتهای ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(6)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(23, { each: true })
|
||||
customerSyncHours?: number[];
|
||||
}
|
||||
|
||||
export class ManualLinkDto {
|
||||
@@ -195,6 +205,12 @@ export class SyncGroupsPageDto {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** اجرای دستی همگامسازی همهٔ مشتریان (وارد + بهروز کردن لینکشدهها) */
|
||||
export class SyncAllCustomersDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
}
|
||||
|
||||
export class ExportPeopleToTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
@@ -447,6 +463,18 @@ export class SyncAsnadReportDto {
|
||||
@MinLength(8)
|
||||
@MaxLength(12)
|
||||
to!: string;
|
||||
|
||||
/** اگر باشد، فقط سندهای همین شخص گرفته میشود */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
/** اگر باشد، فقط مشتریان این گروهها (و زیرگروهها) گرفته میشوند */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(200)
|
||||
groupIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateTahesabInvoiceSettingsDto {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
|
||||
export function pickBirthDate(row: Record<string, unknown>): Date | null {
|
||||
const raw =
|
||||
row.BDate ??
|
||||
row.bDate ??
|
||||
row.Tarikh_Tavalod ??
|
||||
row.tarikh_tavalod ??
|
||||
row.BirthDate;
|
||||
if (raw == null) return null;
|
||||
return parseBirthDate(String(raw));
|
||||
}
|
||||
|
||||
function trimStr(raw: unknown): string | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (!s || s === '-1') return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** کد ملی از پاسخ لیست مشتری تهحساب */
|
||||
export function pickNationalId(row: Record<string, unknown>): string | null {
|
||||
return (
|
||||
trimStr(row.CodeMelli) ??
|
||||
trimStr(row.CodeMeli) ??
|
||||
trimStr(row.codeMeli) ??
|
||||
trimStr(row.NationalId) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function pickAddress(row: Record<string, unknown>): string | null {
|
||||
return trimStr(row.Address) ?? trimStr(row.address) ?? null;
|
||||
}
|
||||
|
||||
export function pickCity(row: Record<string, unknown>): string | null {
|
||||
return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null;
|
||||
}
|
||||
|
||||
/** رسته حساب در تهحساب (عدد یا رشته) */
|
||||
export function pickRasteh(row: Record<string, unknown>): string | number | null {
|
||||
const raw = row.Rasteh ?? row.rasteh ?? row.Raste;
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
const s = String(raw).trim();
|
||||
if (!s || s === '-1') return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) && String(n) === s ? n : s;
|
||||
}
|
||||
|
||||
function asCustomFieldRecord(raw: unknown): Record<string, unknown> {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function asAddressList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((a) => (typeof a === 'string' ? a.trim() : ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلدهای پروفایل قابلدریافت از DoListMoshtari را روی شخص مینشاند.
|
||||
* مقدار خالی تهحساب، دادهٔ موجود CRM را پاک نمیکند.
|
||||
*/
|
||||
export function tahesabProfilePatch(
|
||||
row: Record<string, unknown>,
|
||||
existing: {
|
||||
birthDate: Date | null;
|
||||
addresses: unknown;
|
||||
customFields: unknown;
|
||||
},
|
||||
): Prisma.PersonUpdateInput {
|
||||
const data: Prisma.PersonUpdateInput = {};
|
||||
const birthDate = pickBirthDate(row);
|
||||
if (!existing.birthDate && birthDate) data.birthDate = birthDate;
|
||||
|
||||
const address = pickAddress(row);
|
||||
const city = pickCity(row);
|
||||
if (address) {
|
||||
const line = city ? `${address}، ${city}` : address;
|
||||
data.addresses = [line] as Prisma.InputJsonValue;
|
||||
} else if (city && asAddressList(existing.addresses).length === 0) {
|
||||
data.addresses = [city] as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
const fields = asCustomFieldRecord(existing.customFields);
|
||||
let fieldsChanged = false;
|
||||
const nationalId = pickNationalId(row);
|
||||
if (nationalId) {
|
||||
fields.nationalId = nationalId;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (city) {
|
||||
fields.city = city;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (address) {
|
||||
fields.address = address;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
const rasteh = pickRasteh(row);
|
||||
if (rasteh != null) {
|
||||
fields.rasteh = rasteh;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue;
|
||||
return data;
|
||||
}
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { applyPersonGenderTitle } from '@mento/shared';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import {
|
||||
CreateInTahesabDto,
|
||||
@@ -20,10 +26,25 @@ import {
|
||||
SearchTahesabDto,
|
||||
SyncGroupsPageDto,
|
||||
} from './dto';
|
||||
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
||||
import { formatJalaliDate } from '../../common/excel';
|
||||
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
||||
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
|
||||
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
||||
|
||||
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
||||
function canonicalMobileUpdate(
|
||||
incoming: string | null,
|
||||
current: { mobile: string | null; phone: string | null },
|
||||
): { mobile?: string; phone?: string } {
|
||||
const mobile =
|
||||
canonicalIranMobile(incoming) ??
|
||||
canonicalIranMobile(current.mobile) ??
|
||||
canonicalIranMobile(current.phone);
|
||||
if (!mobile) return {};
|
||||
const phone = canonicalIranMobile(current.phone);
|
||||
return phone ? { mobile, phone } : { mobile };
|
||||
}
|
||||
|
||||
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
||||
const LIST_PAGE_SIZE = 2000;
|
||||
const LIST_TIMEOUT_MS = 55_000;
|
||||
@@ -63,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
|
||||
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
|
||||
}
|
||||
|
||||
function pickBirthDate(row: Record<string, unknown>): Date | null {
|
||||
const raw =
|
||||
row.BDate ??
|
||||
row.bDate ??
|
||||
row.Tarikh_Tavalod ??
|
||||
row.tarikh_tavalod ??
|
||||
row.BirthDate;
|
||||
if (raw == null) return null;
|
||||
return parseBirthDate(String(raw));
|
||||
}
|
||||
|
||||
function firstAddress(raw: unknown): string | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const first = raw.find((a) => typeof a === 'string' && a.trim());
|
||||
@@ -418,6 +428,77 @@ export class TahesabPersonLinkService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* کدهایی که لینک ندارند ولی شمارهشان مال مشتریِ از قبل لینکشدهٔ همین اتصال است.
|
||||
* یک شخص فقط یک کد تهحساب دارد؛ این کدها مشتری تازه نیستند.
|
||||
*/
|
||||
private async duplicateOwners(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
rows: Array<{ moshtariCode: number | null; tel: string }>,
|
||||
) {
|
||||
const pending = rows.filter(
|
||||
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
|
||||
);
|
||||
const keys = [
|
||||
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
|
||||
];
|
||||
const out = new Map<
|
||||
number,
|
||||
{ moshtariCode: number; personId: string; personName: string }
|
||||
>();
|
||||
if (!keys.length) return out;
|
||||
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
|
||||
accountingLinks: { some: { connectionId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
phone: true,
|
||||
accountingLinks: {
|
||||
where: { connectionId },
|
||||
select: { moshtariCode: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const byKey = new Map<
|
||||
string,
|
||||
{ moshtariCode: number; personId: string; personName: string }
|
||||
>();
|
||||
for (const person of people) {
|
||||
const link = person.accountingLinks[0];
|
||||
if (!link) continue;
|
||||
const info = {
|
||||
moshtariCode: link.moshtariCode,
|
||||
personId: person.id,
|
||||
personName: `${person.firstName} ${person.lastName}`.trim(),
|
||||
};
|
||||
for (const key of [
|
||||
...phoneIdentityKeys(person.mobile),
|
||||
...phoneIdentityKeys(person.phone),
|
||||
]) {
|
||||
if (!byKey.has(key)) byKey.set(key, info);
|
||||
}
|
||||
}
|
||||
for (const row of pending) {
|
||||
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
|
||||
const hit = byKey.get(key);
|
||||
if (hit && hit.moshtariCode !== row.moshtariCode) {
|
||||
out.set(row.moshtariCode, hit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async search(organizationId: string, dto: SearchTahesabDto) {
|
||||
const { api } = await this.connections.getApi(
|
||||
organizationId,
|
||||
@@ -496,7 +577,7 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
|
||||
|
||||
const results = mapped.map((row) => {
|
||||
const linked = mapped.map((row) => {
|
||||
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
|
||||
return {
|
||||
...row,
|
||||
@@ -507,6 +588,22 @@ export class TahesabPersonLinkService {
|
||||
: null,
|
||||
};
|
||||
});
|
||||
const duplicates = await this.duplicateOwners(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linked.filter((row) => !row.alreadyLinked),
|
||||
);
|
||||
const results = linked.map((row) => {
|
||||
if (row.alreadyLinked || row.moshtariCode == null) return row;
|
||||
const dup = duplicates.get(row.moshtariCode);
|
||||
if (!dup) return row;
|
||||
return {
|
||||
...row,
|
||||
duplicateOfCode: dup.moshtariCode,
|
||||
duplicatePersonId: dup.personId,
|
||||
duplicatePersonName: dup.personName,
|
||||
};
|
||||
});
|
||||
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
@@ -538,6 +635,7 @@ export class TahesabPersonLinkService {
|
||||
organizationId: string,
|
||||
dto: ImportFromTahesabDto,
|
||||
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
|
||||
options?: { skipJob?: boolean },
|
||||
) {
|
||||
const clash = await this.prisma.personAccountingLink.findFirst({
|
||||
where: {
|
||||
@@ -573,10 +671,10 @@ export class TahesabPersonLinkService {
|
||||
}
|
||||
const name = pickName(row) || `مشتری تهحساب ${dto.moshtariCode}`;
|
||||
const tel = pickTel(row);
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const firstName = parts[0] || name;
|
||||
const lastName = parts.slice(1).join(' ') || '—';
|
||||
const mobile = tel || null;
|
||||
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
|
||||
const firstName = named.firstName;
|
||||
const lastName = named.lastName;
|
||||
const mobile = storedPhoneNumber(tel);
|
||||
const sourceRef = String(dto.moshtariCode);
|
||||
|
||||
let personId = dto.personId;
|
||||
@@ -591,6 +689,8 @@ export class TahesabPersonLinkService {
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: prevFirst || null,
|
||||
@@ -600,29 +700,49 @@ export class TahesabPersonLinkService {
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...(mobile
|
||||
? {
|
||||
mobile: existing.mobile || mobile,
|
||||
phone: existing.phone || mobile,
|
||||
}
|
||||
: {}),
|
||||
...canonicalMobileUpdate(mobile, existing),
|
||||
...tahesabProfilePatch(row, existing),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
||||
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته میشود؛
|
||||
// وگرنه upsert لینک، کد قبلی را جابهجا و آن مشتری را دوباره «واردنشده» میکند.
|
||||
const byMobile = mobile
|
||||
? await this.prisma.person.findFirst({
|
||||
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمیخورند
|
||||
// تا کد قبلی جابهجا نشود و این کد هم مشتری تکراری نسازد.
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const matches = mobileKeys.length
|
||||
? await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
accountingLinks: { none: { connectionId: dto.connectionId } },
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
include: {
|
||||
accountingLinks: {
|
||||
where: { connectionId: dto.connectionId },
|
||||
select: { moshtariCode: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
: [];
|
||||
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
||||
if (byMobile) {
|
||||
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
|
||||
if (linkedCode != null && linkedCode !== dto.moshtariCode) {
|
||||
const existing = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId: byMobile.id, connectionId: dto.connectionId },
|
||||
include: {
|
||||
connection: { select: { label: true, dbName: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('لینک تهحساب این مشتری یافت نشد.');
|
||||
}
|
||||
return {
|
||||
personId: byMobile.id,
|
||||
link: this.linkDto(existing),
|
||||
duplicateOf: linkedCode,
|
||||
};
|
||||
}
|
||||
const nameChanged =
|
||||
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
||||
`${firstName} ${lastName}`.trim();
|
||||
@@ -631,6 +751,8 @@ export class TahesabPersonLinkService {
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: byMobile.firstName || null,
|
||||
@@ -640,22 +762,32 @@ export class TahesabPersonLinkService {
|
||||
...carryPreviousSource(byMobile, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...canonicalMobileUpdate(mobile, byMobile),
|
||||
...tahesabProfilePatch(row, byMobile),
|
||||
},
|
||||
});
|
||||
personId = byMobile.id;
|
||||
} else {
|
||||
const profile = tahesabProfilePatch(row, {
|
||||
birthDate: null,
|
||||
addresses: [],
|
||||
customFields: {},
|
||||
});
|
||||
const created = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId,
|
||||
firstName,
|
||||
lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile,
|
||||
phone: mobile,
|
||||
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
birthDate: pickBirthDate(row),
|
||||
birthDate: (profile.birthDate as Date | undefined) ?? null,
|
||||
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
|
||||
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
|
||||
},
|
||||
});
|
||||
personId = created.id;
|
||||
@@ -686,18 +818,184 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
}
|
||||
|
||||
/** پروفایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||
private async refreshLinkedCustomer(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
personId: string,
|
||||
moshtariCode: number,
|
||||
row: Record<string, unknown>,
|
||||
) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const picked = pickName(row).trim();
|
||||
const named = picked
|
||||
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
|
||||
: null;
|
||||
const nameChanged =
|
||||
named != null &&
|
||||
namesDiffer(
|
||||
existing.firstName,
|
||||
existing.lastName,
|
||||
named.firstName,
|
||||
named.lastName,
|
||||
);
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
...(named
|
||||
? {
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: existing.firstName || null,
|
||||
previousLastName: existing.lastName || null,
|
||||
}
|
||||
: {}),
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef: String(moshtariCode),
|
||||
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
||||
...tahesabProfilePatch(row, existing),
|
||||
},
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
||||
}
|
||||
|
||||
/**
|
||||
* همهٔ مشتریان تهحساب را صفحه به صفحه میخواند.
|
||||
* کد تازه وارد میشود؛ کد لینکشده نام و شماره و گروهش تازه میشود.
|
||||
*/
|
||||
async syncAllCustomers(organizationId: string, connectionId: string) {
|
||||
const { api } = await this.connections.getApi(organizationId, connectionId);
|
||||
let page = 1;
|
||||
let emptyStreak = 0;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let duplicate = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
const maxPages = 300;
|
||||
|
||||
while (emptyStreak < 2 && page <= maxPages) {
|
||||
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
|
||||
try {
|
||||
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'خواندن تهحساب ناموفق بود';
|
||||
errors.push(message);
|
||||
break;
|
||||
}
|
||||
if (!listed.rows.length) {
|
||||
emptyStreak += 1;
|
||||
page += 1;
|
||||
continue;
|
||||
}
|
||||
emptyStreak = 0;
|
||||
const rowsByCode = new Map<number, Record<string, unknown>>();
|
||||
for (const row of listed.rows) {
|
||||
const code = pickCode(row);
|
||||
if (code != null && code >= 1) rowsByCode.set(code, row);
|
||||
}
|
||||
const links = rowsByCode.size
|
||||
? await this.prisma.personAccountingLink.findMany({
|
||||
where: {
|
||||
connectionId,
|
||||
moshtariCode: { in: [...rowsByCode.keys()] },
|
||||
},
|
||||
select: { moshtariCode: true, personId: true },
|
||||
})
|
||||
: [];
|
||||
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
|
||||
for (const [code, row] of rowsByCode) {
|
||||
try {
|
||||
const personId = linkedByCode.get(code);
|
||||
if (personId) {
|
||||
await this.refreshLinkedCustomer(
|
||||
organizationId,
|
||||
connectionId,
|
||||
personId,
|
||||
code,
|
||||
row,
|
||||
);
|
||||
updated += 1;
|
||||
} else {
|
||||
const created = await this.importFromTahesab(
|
||||
organizationId,
|
||||
{ connectionId, moshtariCode: code },
|
||||
{ api, row },
|
||||
{ skipJob: true },
|
||||
);
|
||||
if (created.duplicateOf) duplicate += 1;
|
||||
else imported += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
if (errors.length < 5) {
|
||||
errors.push(err instanceof Error ? err.message : 'خطا');
|
||||
}
|
||||
}
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if (page > maxPages && emptyStreak < 2) {
|
||||
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
|
||||
}
|
||||
|
||||
const summary = [
|
||||
`${imported} وارد شد`,
|
||||
`${updated} بهروز شد`,
|
||||
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
|
||||
`${failed} خطا`,
|
||||
].join('، ');
|
||||
const error = errors.length ? errors.join('؛ ') : null;
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
connectionId,
|
||||
kind: 'customer-sync',
|
||||
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
||||
result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: summary,
|
||||
customerSyncLastError: error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
||||
return {
|
||||
imported,
|
||||
updated,
|
||||
duplicate,
|
||||
failed,
|
||||
summary,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
||||
@@ -792,19 +1090,16 @@ export class TahesabPersonLinkService {
|
||||
try {
|
||||
const linkedPersonId = linkedByCode.get(moshtariCode);
|
||||
if (linkedPersonId) {
|
||||
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
|
||||
if (birthDate) {
|
||||
await this.prisma.person.updateMany({
|
||||
where: { id: linkedPersonId, birthDate: null },
|
||||
data: { birthDate },
|
||||
});
|
||||
const row = rowsByCode.get(moshtariCode);
|
||||
if (row) {
|
||||
await this.refreshLinkedCustomer(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linkedPersonId,
|
||||
moshtariCode,
|
||||
row,
|
||||
);
|
||||
}
|
||||
await this.applyTahesabGroup(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linkedPersonId,
|
||||
rowsByCode.get(moshtariCode),
|
||||
);
|
||||
items.push({
|
||||
moshtariCode,
|
||||
ok: true,
|
||||
@@ -818,6 +1113,16 @@ export class TahesabPersonLinkService {
|
||||
{ connectionId: dto.connectionId, moshtariCode },
|
||||
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
|
||||
);
|
||||
if (r.duplicateOf) {
|
||||
items.push({
|
||||
moshtariCode,
|
||||
ok: true,
|
||||
skipped: true,
|
||||
personId: r.personId,
|
||||
error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
items.push({ moshtariCode, ok: true, personId: r.personId });
|
||||
} catch (e) {
|
||||
const message =
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/** طبقهبندی و خالصکردن فروش/خرید بعد از مرجوعی، برای گزارش تهحساب. */
|
||||
|
||||
export const SALES_FACTOR_KEYS = [
|
||||
'karsakhte',
|
||||
'etiket_gold',
|
||||
'etiket_jewel',
|
||||
'bullion',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
export type SalesFactorKey = (typeof SALES_FACTOR_KEYS)[number];
|
||||
|
||||
export const SALES_FACTOR_LABELS: Record<SalesFactorKey, string> = {
|
||||
karsakhte: 'کار ساخته',
|
||||
etiket_gold: 'اتیکت طلا',
|
||||
etiket_jewel: 'اتیکت جواهر',
|
||||
bullion: 'شمش و سکه',
|
||||
other: 'سایر',
|
||||
};
|
||||
|
||||
export type DocDirection = 'in' | 'out' | 'purchase_return' | 'sale_return' | 'other';
|
||||
|
||||
export type SalesDoc = {
|
||||
bucketKey: string;
|
||||
factor: SalesFactorKey;
|
||||
direction: DocDirection;
|
||||
weight: number;
|
||||
mali: number;
|
||||
count: number;
|
||||
percent: number | null;
|
||||
tarikh: string;
|
||||
};
|
||||
|
||||
export type PercentStats = {
|
||||
first: number | null;
|
||||
last: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
avg: number | null;
|
||||
};
|
||||
|
||||
export type MovementStats = {
|
||||
grossWeight: number;
|
||||
returnWeight: number;
|
||||
netWeight: number;
|
||||
grossMali: number;
|
||||
returnMali: number;
|
||||
netMali: number;
|
||||
grossCount: number;
|
||||
returnCount: number;
|
||||
netCount: number;
|
||||
percent: PercentStats | null;
|
||||
};
|
||||
|
||||
const EMPTY_MOVE: MovementStats = {
|
||||
grossWeight: 0,
|
||||
returnWeight: 0,
|
||||
netWeight: 0,
|
||||
grossMali: 0,
|
||||
returnMali: 0,
|
||||
netMali: 0,
|
||||
grossCount: 0,
|
||||
returnCount: 0,
|
||||
netCount: 0,
|
||||
percent: null,
|
||||
};
|
||||
|
||||
function norm(raw: string): string {
|
||||
return raw.replace(/\u064A/g, '\u06CC').replace(/\u0643/g, '\u06A9').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function classifyDirection(noRaw: string): DocDirection {
|
||||
const no = norm(noRaw);
|
||||
if (no.includes('ورود کارساخته مرجوع')) return 'sale_return';
|
||||
if (no.includes('خروج کارساخته مرجوع')) return 'purchase_return';
|
||||
if (no.includes('ورود کارساخته')) return 'in';
|
||||
if (no.includes('خروج کارساخته')) return 'out';
|
||||
const isReturn = /مرجوع|برگشت/.test(no);
|
||||
const isBuy = /خرید/.test(no);
|
||||
const isSell = /فروش/.test(no);
|
||||
if (isReturn && isBuy) return 'purchase_return';
|
||||
if (isReturn) return 'sale_return';
|
||||
if (isBuy || (/ورود/.test(no) && !isReturn)) return 'in';
|
||||
if (isSell || (/خروج/.test(no) && !isReturn)) return 'out';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function classifyFactor(noRaw: string, noKar: string | null | undefined): SalesFactorKey {
|
||||
const blob = norm(`${noRaw} ${noKar ?? ''}`);
|
||||
if (blob.includes('کارساخته') || blob.includes('کار ساخته')) return 'karsakhte';
|
||||
if (/شمش|سکه/.test(blob)) return 'bullion';
|
||||
if (/جواهر|ساچمه/.test(blob)) return 'etiket_jewel';
|
||||
if (blob.includes('اتیکت')) return 'etiket_gold';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function combinedPercent(
|
||||
darsad: number | null | undefined,
|
||||
talayee: number | null | undefined,
|
||||
maliat: number | null | undefined,
|
||||
): number | null {
|
||||
const parts = [darsad, talayee, maliat].filter(
|
||||
(n): n is number => n != null && Number.isFinite(n),
|
||||
);
|
||||
if (!parts.length) return null;
|
||||
return parts.reduce((sum, n) => sum + n, 0);
|
||||
}
|
||||
|
||||
export function positiveAmount(vazn: number | null | undefined, tabdil: number | null | undefined): number {
|
||||
if (vazn != null && Number.isFinite(vazn) && vazn !== 0) return Math.abs(vazn);
|
||||
if (tabdil != null && Number.isFinite(tabdil)) return Math.abs(tabdil);
|
||||
return 0;
|
||||
}
|
||||
|
||||
type Piece = {
|
||||
tarikh: string;
|
||||
seq: number;
|
||||
weight: number;
|
||||
mali: number;
|
||||
count: number;
|
||||
percent: number | null;
|
||||
};
|
||||
|
||||
/** مرجوعی از آخرین سندهای همان نام کم میشود تا فروش/خریدِ برگشتی در آمار نماند. */
|
||||
function remainingAfterReturns(gross: Piece[], returns: Piece[]): Piece[] {
|
||||
const docs = gross.map((d) => ({ ...d }));
|
||||
let leftW = returns.reduce((s, d) => s + d.weight, 0);
|
||||
let leftM = returns.reduce((s, d) => s + d.mali, 0);
|
||||
let leftC = returns.reduce((s, d) => s + d.count, 0);
|
||||
const order = [...docs].sort((a, b) => {
|
||||
if (a.tarikh !== b.tarikh) return b.tarikh.localeCompare(a.tarikh);
|
||||
return b.seq - a.seq;
|
||||
});
|
||||
for (const doc of order) {
|
||||
if (leftW <= 0 && leftM <= 0 && leftC <= 0) break;
|
||||
const takeW = Math.min(doc.weight, Math.max(leftW, 0));
|
||||
const takeM = Math.min(doc.mali, Math.max(leftM, 0));
|
||||
const takeC = Math.min(doc.count, Math.max(leftC, 0));
|
||||
doc.weight -= takeW;
|
||||
doc.mali -= takeM;
|
||||
doc.count -= takeC;
|
||||
leftW -= takeW;
|
||||
leftM -= takeM;
|
||||
leftC -= takeC;
|
||||
}
|
||||
return docs.filter((d) => d.weight > 1e-9 || d.mali > 1e-6 || d.count > 1e-9);
|
||||
}
|
||||
|
||||
function percentOf(docs: Piece[]): PercentStats | null {
|
||||
const withPercent = docs.filter((d) => d.percent != null && d.weight > 1e-9);
|
||||
if (!withPercent.length) return null;
|
||||
const ordered = [...withPercent].sort((a, b) => {
|
||||
if (a.tarikh !== b.tarikh) return a.tarikh.localeCompare(b.tarikh);
|
||||
return a.seq - b.seq;
|
||||
});
|
||||
const values = ordered.map((d) => d.percent!);
|
||||
const weight = ordered.reduce((s, d) => s + d.weight, 0);
|
||||
const avg =
|
||||
weight > 0
|
||||
? ordered.reduce((s, d) => s + d.percent! * d.weight, 0) / weight
|
||||
: null;
|
||||
return {
|
||||
first: ordered[0]?.percent ?? null,
|
||||
last: ordered[ordered.length - 1]?.percent ?? null,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
avg,
|
||||
};
|
||||
}
|
||||
|
||||
export function movementStats(lines: SalesDoc[], side: 'in' | 'out'): MovementStats {
|
||||
const returnDir: DocDirection = side === 'in' ? 'purchase_return' : 'sale_return';
|
||||
const groups = new Map<string, { gross: Piece[]; ret: Piece[] }>();
|
||||
let seq = 0;
|
||||
for (const line of lines) {
|
||||
if (line.direction !== side && line.direction !== returnDir) continue;
|
||||
const bag = groups.get(line.bucketKey) ?? { gross: [], ret: [] };
|
||||
const piece: Piece = {
|
||||
tarikh: line.tarikh,
|
||||
seq: seq++,
|
||||
weight: Math.abs(line.weight),
|
||||
mali: Math.abs(line.mali),
|
||||
count: Math.abs(line.count),
|
||||
percent: line.percent,
|
||||
};
|
||||
if (line.direction === side) bag.gross.push(piece);
|
||||
else bag.ret.push(piece);
|
||||
groups.set(line.bucketKey, bag);
|
||||
}
|
||||
if (!groups.size) return { ...EMPTY_MOVE };
|
||||
|
||||
let grossWeight = 0;
|
||||
let returnWeight = 0;
|
||||
let grossMali = 0;
|
||||
let returnMali = 0;
|
||||
let grossCount = 0;
|
||||
let returnCount = 0;
|
||||
const kept: Piece[] = [];
|
||||
for (const bag of groups.values()) {
|
||||
grossWeight += bag.gross.reduce((s, d) => s + d.weight, 0);
|
||||
returnWeight += bag.ret.reduce((s, d) => s + d.weight, 0);
|
||||
grossMali += bag.gross.reduce((s, d) => s + d.mali, 0);
|
||||
returnMali += bag.ret.reduce((s, d) => s + d.mali, 0);
|
||||
grossCount += bag.gross.reduce((s, d) => s + d.count, 0);
|
||||
returnCount += bag.ret.reduce((s, d) => s + d.count, 0);
|
||||
kept.push(...remainingAfterReturns(bag.gross, bag.ret));
|
||||
}
|
||||
return {
|
||||
grossWeight,
|
||||
returnWeight,
|
||||
netWeight: Math.max(0, grossWeight - returnWeight),
|
||||
grossMali,
|
||||
returnMali,
|
||||
netMali: Math.max(0, grossMali - returnMali),
|
||||
grossCount,
|
||||
returnCount,
|
||||
netCount: Math.max(0, grossCount - returnCount),
|
||||
percent: percentOf(kept),
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SearchTahesabDto,
|
||||
SearchTahesabProductsDto,
|
||||
SyncAsnadReportDto,
|
||||
SyncAllCustomersDto,
|
||||
SyncGroupsPageDto,
|
||||
UpdateTahesabConnectionDto,
|
||||
UpdateTahesabInvoiceSettingsDto,
|
||||
@@ -237,6 +238,16 @@ export class TahesabController {
|
||||
return this.links.syncGroupsPage(user.organizationId, dto);
|
||||
}
|
||||
|
||||
/** همهٔ مشتریان تهحساب: تازهها وارد، لینکشدهها (نام/موبایل/کدملی/آدرس/شهر/رسته/گروه) بهروز */
|
||||
@Post('sync/customers-now')
|
||||
@Roles('owner', 'admin')
|
||||
syncCustomersNow(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: SyncAllCustomersDto,
|
||||
) {
|
||||
return this.links.syncAllCustomers(user.organizationId, dto.connectionId);
|
||||
}
|
||||
|
||||
@Get('sync/unlinked-people')
|
||||
@Roles('owner', 'admin')
|
||||
unlinkedPeople(
|
||||
@@ -309,6 +320,20 @@ export class TahesabController {
|
||||
dto.connectionId,
|
||||
dto.from,
|
||||
dto.to,
|
||||
{ personId: dto.personId, groupIds: dto.groupIds },
|
||||
);
|
||||
}
|
||||
|
||||
@Get('reports/asnad/customers')
|
||||
searchAsnadCustomers(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('q') q?: string,
|
||||
) {
|
||||
return this.asnadReport.searchCustomers(
|
||||
user.organizationId,
|
||||
connectionId,
|
||||
q ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,6 +345,27 @@ export class TahesabController {
|
||||
return this.asnadReport.getSyncJob(user.organizationId, jobId);
|
||||
}
|
||||
|
||||
@Get('reports/asnad/overview')
|
||||
asnadReportOverview(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('groupIds') groupIds?: string | string[],
|
||||
@Query('personId') personId?: string,
|
||||
@Query('jensFelez') jensFelez?: string,
|
||||
) {
|
||||
if (!from || !to) {
|
||||
throw new BadRequestException('پارامتر from و to الزامی است.');
|
||||
}
|
||||
return this.asnadReport.overview(user.organizationId, {
|
||||
connectionId,
|
||||
from,
|
||||
to,
|
||||
...this.parseAsnadFilters(groupIds, personId, undefined, jensFelez),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('reports/asnad/summary')
|
||||
asnadReportSummary(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
|
||||
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
|
||||
TahesabDocumentsService,
|
||||
TahesabAsnadReportService,
|
||||
TahesabInvoiceSettingsService,
|
||||
TahesabCustomerSyncScheduler,
|
||||
],
|
||||
exports: [
|
||||
TahesabCryptoService,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { AiService } from '../../ai/ai.service';
|
||||
import { NotesService } from '../../notes/notes.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
import { DaftaresomaClient } from './daftareshoma-client';
|
||||
import { parseDaftaresomaRecordingRef } from './daftareshoma-webhook';
|
||||
import { SttClientService } from './stt-client.service';
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
@@ -17,6 +20,8 @@ export class CallSummaryService {
|
||||
private readonly stt: SttClientService,
|
||||
private readonly ai: AiService,
|
||||
private readonly notes: NotesService,
|
||||
private readonly connections: VoipConnectionsService,
|
||||
private readonly daftareshoma: DaftaresomaClient,
|
||||
) {}
|
||||
|
||||
@Cron('*/1 * * * *')
|
||||
@@ -74,7 +79,31 @@ export class CallSummaryService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
|
||||
const daftId = parseDaftaresomaRecordingRef(row.recordingUrl);
|
||||
if (daftId) {
|
||||
const conn = await this.prisma.voipConnection.findFirst({
|
||||
where: { id: row.connectionId },
|
||||
});
|
||||
const token = conn
|
||||
? await this.connections.decryptApiToken(conn)
|
||||
: null;
|
||||
if (!token) {
|
||||
await this.markFailed(
|
||||
id,
|
||||
row.summaryAttempts,
|
||||
'توکن API دفتر شما برای دانلود ضبط موجود نیست',
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const audio = await this.daftareshoma.downloadRecording(
|
||||
token,
|
||||
daftId,
|
||||
);
|
||||
transcript = await this.stt.transcribeAudio(audio);
|
||||
} else {
|
||||
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
|
||||
}
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id },
|
||||
data: { transcript },
|
||||
|
||||
@@ -11,8 +11,13 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { NotificationsService } from '../../notifications/notifications.service';
|
||||
import { normalizePhone } from '../../contacts/normalize';
|
||||
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
import { DaftaresomaClient } from './daftareshoma-client';
|
||||
import {
|
||||
daftareshomaRecordingRef,
|
||||
parseDaftaresomaRecordingRef,
|
||||
} from './daftareshoma-webhook';
|
||||
|
||||
function digitsOnly(value: string | null | undefined): string {
|
||||
return (value ?? '').replace(/\D/g, '');
|
||||
@@ -35,6 +40,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
|
||||
variants.add(`0${d}`);
|
||||
variants.add(`98${d}`);
|
||||
}
|
||||
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
@@ -56,19 +62,32 @@ export class VoipCallsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly connections: VoipConnectionsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly daftareshoma: DaftaresomaClient,
|
||||
) {}
|
||||
|
||||
async listRecent(
|
||||
organizationId: string,
|
||||
connectionId?: string,
|
||||
opts?: {
|
||||
connectionId?: string;
|
||||
direction?: string;
|
||||
status?: string;
|
||||
lineExtension?: string;
|
||||
},
|
||||
): Promise<VoipCallLogDto[]> {
|
||||
const rows = await this.prisma.voipCallLog.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(opts?.connectionId ? { connectionId: opts.connectionId } : {}),
|
||||
...(opts?.direction === 'inbound' || opts?.direction === 'outbound'
|
||||
? { direction: opts.direction }
|
||||
: {}),
|
||||
...(opts?.status?.trim() ? { status: opts.status.trim() } : {}),
|
||||
...(opts?.lineExtension?.trim()
|
||||
? { lineExtension: opts.lineExtension.trim() }
|
||||
: {}),
|
||||
},
|
||||
orderBy: { syncedAt: 'desc' },
|
||||
take: 50,
|
||||
take: 100,
|
||||
});
|
||||
const personIds = rows
|
||||
.map((r) => r.personId)
|
||||
@@ -90,6 +109,7 @@ export class VoipCallsService {
|
||||
status: r.status,
|
||||
fromNumber: r.fromNumber,
|
||||
toNumber: r.toNumber,
|
||||
lineExtension: r.lineExtension ?? null,
|
||||
personId: r.personId,
|
||||
personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
|
||||
activityId: r.activityId,
|
||||
@@ -126,6 +146,9 @@ export class VoipCallsService {
|
||||
const toDigits = digitsOnly(event.to ?? event.callee);
|
||||
const fromNumber = fromNormalized || fromDigits || null;
|
||||
const toNumber = toNormalized || toDigits || null;
|
||||
const lineRaw = String(event.lineExtension ?? '').trim();
|
||||
const lineExtension =
|
||||
normalizePhone(lineRaw) || digitsOnly(lineRaw) || lineRaw || null;
|
||||
const matchNumber = direction === 'inbound' ? fromNumber : toNumber;
|
||||
const personId = await this.findPersonId(
|
||||
conn.organizationId,
|
||||
@@ -192,6 +215,7 @@ export class VoipCallsService {
|
||||
status,
|
||||
fromNumber,
|
||||
toNumber,
|
||||
lineExtension: lineExtension ?? existing.lineExtension,
|
||||
personId: personId ?? existing.personId,
|
||||
activityId: activityId ?? existing.activityId,
|
||||
durationSec,
|
||||
@@ -212,6 +236,7 @@ export class VoipCallsService {
|
||||
status,
|
||||
fromNumber,
|
||||
toNumber,
|
||||
lineExtension,
|
||||
personId,
|
||||
activityId,
|
||||
durationSec,
|
||||
@@ -239,6 +264,17 @@ export class VoipCallsService {
|
||||
await this.notifyIncoming(conn.organizationId, personId, fromNumber);
|
||||
}
|
||||
|
||||
if (
|
||||
conn.provider === 'daftareshoma' &&
|
||||
['completed', 'answered', 'missed'].includes(status)
|
||||
) {
|
||||
void this.enrichDaftaresomaRecording(connectionId, callId).catch((e) => {
|
||||
this.logger.warn(
|
||||
`daftareshoma recording enrich failed: ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const link = await this.prisma.voipCallLog.findUnique({
|
||||
where: {
|
||||
connectionId_externalCallId: {
|
||||
@@ -260,10 +296,67 @@ export class VoipCallsService {
|
||||
userId: string,
|
||||
number: string,
|
||||
personId?: string,
|
||||
): Promise<{ ok: true; mode: 'url' | 'tel'; dialUrl: string }> {
|
||||
): Promise<{ ok: true; mode: 'url' | 'tel' | 'daftareshoma'; dialUrl: string }> {
|
||||
const phone = normalizePhone(number) ?? digitsOnly(number);
|
||||
if (!phone) throw new BadRequestException('شماره معتبر نیست');
|
||||
|
||||
const daftareshomaConn = await this.prisma.voipConnection.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
enabled: true,
|
||||
provider: 'daftareshoma',
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (daftareshomaConn) {
|
||||
const token = await this.connections.decryptApiToken(daftareshomaConn);
|
||||
const fromNumber = daftareshomaConn.fromNumber?.trim();
|
||||
const callerExtension = daftareshomaConn.callerExtension?.trim();
|
||||
if (!token || !fromNumber || !callerExtension) {
|
||||
throw new BadRequestException(
|
||||
'اتصال دفتر شما ناقص است؛ توکن، شماره مبدأ و خط را تکمیل کنید',
|
||||
);
|
||||
}
|
||||
const toE164 = this.toPlusDigits(phone);
|
||||
const fromE164 = this.toPlusDigits(fromNumber);
|
||||
const extE164 = this.toPlusDigits(callerExtension);
|
||||
try {
|
||||
await this.daftareshoma.outgoingCall(token, {
|
||||
fromNumber: fromE164,
|
||||
toNumber: toE164,
|
||||
callerExtension: extE164,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'خطا';
|
||||
await this.prisma.voipConnection.update({
|
||||
where: { id: daftareshomaConn.id },
|
||||
data: { lastError: msg.slice(0, 400) },
|
||||
});
|
||||
throw new BadRequestException(msg);
|
||||
}
|
||||
|
||||
await this.prisma.voipCallLog.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: daftareshomaConn.id,
|
||||
externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`,
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
fromNumber: fromE164,
|
||||
toNumber: toE164,
|
||||
personId: personId ?? null,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mode: 'daftareshoma',
|
||||
dialUrl: `daftareshoma:${toE164}`,
|
||||
};
|
||||
}
|
||||
|
||||
const conn = await this.prisma.voipConnection.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
@@ -317,6 +410,69 @@ export class VoipCallsService {
|
||||
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` };
|
||||
}
|
||||
|
||||
private toPlusDigits(raw: string): string {
|
||||
const d = digitsOnly(raw);
|
||||
if (!d) return raw.trim();
|
||||
if (raw.trim().startsWith('+')) return `+${d}`;
|
||||
if (d.startsWith('98')) return `+${d}`;
|
||||
if (d.startsWith('0') && d.length >= 10) return `+98${d.slice(1)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `+98${d}`;
|
||||
return `+${d}`;
|
||||
}
|
||||
|
||||
private async enrichDaftaresomaRecording(
|
||||
connectionId: string,
|
||||
callId: string,
|
||||
): Promise<void> {
|
||||
const conn = await this.prisma.voipConnection.findFirst({
|
||||
where: { id: connectionId, provider: 'daftareshoma', enabled: true },
|
||||
});
|
||||
if (!conn) return;
|
||||
const log = await this.prisma.voipCallLog.findUnique({
|
||||
where: {
|
||||
connectionId_externalCallId: { connectionId, externalCallId: callId },
|
||||
},
|
||||
});
|
||||
if (!log) return;
|
||||
if (parseDaftaresomaRecordingRef(log.recordingUrl) || log.recordingUrl) {
|
||||
if (
|
||||
parseDaftaresomaRecordingRef(log.recordingUrl) &&
|
||||
!['done', 'pending', 'processing'].includes(log.summaryStatus)
|
||||
) {
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
summaryStatus: 'pending',
|
||||
summaryError: null,
|
||||
summaryNextAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await this.connections.decryptApiToken(conn);
|
||||
if (!token) return;
|
||||
const row = await this.daftareshoma.findCallById(token, callId);
|
||||
const recordingId = row?.recordingId?.trim();
|
||||
if (!recordingId) return;
|
||||
|
||||
const ref = daftareshomaRecordingRef(recordingId);
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
recordingUrl: ref,
|
||||
summaryStatus: ['done', 'pending', 'processing'].includes(
|
||||
log.summaryStatus,
|
||||
)
|
||||
? log.summaryStatus
|
||||
: 'pending',
|
||||
summaryError: null,
|
||||
summaryNextAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private shouldCreateActivity(status: string): boolean {
|
||||
return ['answered', 'completed', 'missed', 'failed'].includes(status);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,17 @@ import type {
|
||||
CreateVoipConnectionRequest,
|
||||
UpdateVoipConnectionRequest,
|
||||
VoipConnectionDto,
|
||||
VoipProvider,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHmac } from 'crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
||||
|
||||
function asProvider(value: string | null | undefined): VoipProvider {
|
||||
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VoipConnectionsService {
|
||||
constructor(
|
||||
@@ -43,19 +48,35 @@ export class VoipConnectionsService {
|
||||
): Promise<VoipConnectionDto> {
|
||||
const label = dto.label?.trim();
|
||||
if (!label) throw new BadRequestException('نام اتصال الزامی است');
|
||||
const provider = asProvider(dto.provider);
|
||||
if (provider === 'daftareshoma') {
|
||||
const token = dto.apiToken?.trim();
|
||||
if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است');
|
||||
// fromNumber / callerExtension فقط برای تماس خروجی از CRM لازماند؛ ثبت وبهوک بدون آنها ممکن است
|
||||
}
|
||||
|
||||
const secret = dto.webhookSecret?.trim();
|
||||
const secretEnc = secret ? this.crypto.encrypt(secret) : null;
|
||||
const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
|
||||
const token = dto.apiToken?.trim();
|
||||
const tokenEnc = token ? this.crypto.encrypt(token) : null;
|
||||
const clickToCallUrl =
|
||||
provider === 'generic' ? this.normalizeClickUrl(dto.clickToCallUrl) : null;
|
||||
|
||||
const row = await this.prisma.voipConnection.create({
|
||||
data: {
|
||||
organizationId,
|
||||
label,
|
||||
provider,
|
||||
enabled: dto.enabled ?? true,
|
||||
clickToCallUrl,
|
||||
fromNumber: dto.fromNumber?.trim() || null,
|
||||
callerExtension: dto.callerExtension?.trim() || null,
|
||||
webhookSecretCiphertext: secretEnc?.ciphertext,
|
||||
webhookSecretIv: secretEnc?.iv,
|
||||
webhookSecretTag: secretEnc?.tag,
|
||||
apiTokenCiphertext: tokenEnc?.ciphertext,
|
||||
apiTokenIv: tokenEnc?.iv,
|
||||
apiTokenTag: tokenEnc?.tag,
|
||||
},
|
||||
});
|
||||
return this.toDto(row, 0);
|
||||
@@ -77,6 +98,12 @@ export class VoipConnectionsService {
|
||||
if (dto.clickToCallUrl !== undefined) {
|
||||
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
|
||||
}
|
||||
if (dto.fromNumber !== undefined) {
|
||||
data.fromNumber = dto.fromNumber?.trim() || null;
|
||||
}
|
||||
if (dto.callerExtension !== undefined) {
|
||||
data.callerExtension = dto.callerExtension?.trim() || null;
|
||||
}
|
||||
if (dto.webhookSecret !== undefined) {
|
||||
const wh = dto.webhookSecret?.trim();
|
||||
if (!wh) {
|
||||
@@ -90,6 +117,19 @@ export class VoipConnectionsService {
|
||||
data.webhookSecretTag = enc.tag;
|
||||
}
|
||||
}
|
||||
if (dto.apiToken !== undefined) {
|
||||
const tok = dto.apiToken?.trim();
|
||||
if (!tok) {
|
||||
data.apiTokenCiphertext = null;
|
||||
data.apiTokenIv = null;
|
||||
data.apiTokenTag = null;
|
||||
} else {
|
||||
const enc = this.crypto.encrypt(tok);
|
||||
data.apiTokenCiphertext = enc.ciphertext;
|
||||
data.apiTokenIv = enc.iv;
|
||||
data.apiTokenTag = enc.tag;
|
||||
}
|
||||
}
|
||||
const row = await this.prisma.voipConnection.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
@@ -124,6 +164,21 @@ export class VoipConnectionsService {
|
||||
});
|
||||
}
|
||||
|
||||
async decryptApiToken(row: {
|
||||
apiTokenCiphertext: string | null;
|
||||
apiTokenIv: string | null;
|
||||
apiTokenTag: string | null;
|
||||
}): Promise<string | null> {
|
||||
if (!row.apiTokenCiphertext || !row.apiTokenIv || !row.apiTokenTag) {
|
||||
return null;
|
||||
}
|
||||
return this.crypto.decrypt({
|
||||
ciphertext: row.apiTokenCiphertext,
|
||||
iv: row.apiTokenIv,
|
||||
tag: row.apiTokenTag,
|
||||
});
|
||||
}
|
||||
|
||||
verifySignature(
|
||||
rawBody: string | Buffer,
|
||||
signature: string | undefined,
|
||||
@@ -153,9 +208,13 @@ export class VoipConnectionsService {
|
||||
row: {
|
||||
id: string;
|
||||
label: string;
|
||||
provider?: string | null;
|
||||
enabled: boolean;
|
||||
webhookSecretCiphertext: string | null;
|
||||
apiTokenCiphertext?: string | null;
|
||||
clickToCallUrl: string | null;
|
||||
fromNumber?: string | null;
|
||||
callerExtension?: string | null;
|
||||
lastEventAt: Date | null;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
@@ -166,9 +225,13 @@ export class VoipConnectionsService {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
provider: asProvider(row.provider),
|
||||
enabled: row.enabled,
|
||||
webhookSecretSet: Boolean(row.webhookSecretCiphertext),
|
||||
apiTokenSet: Boolean(row.apiTokenCiphertext),
|
||||
clickToCallUrl: row.clickToCallUrl,
|
||||
fromNumber: row.fromNumber ?? null,
|
||||
callerExtension: row.callerExtension ?? null,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
lastError: row.lastError,
|
||||
callCount,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
const BASE_URL = 'https://coreapi.daftareshoma.com/api/Customize';
|
||||
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export type DaftaresomaOutgoingCallInput = {
|
||||
fromNumber: string;
|
||||
toNumber: string;
|
||||
callerExtension: string;
|
||||
};
|
||||
|
||||
export type DaftaresomaCallSearchRow = {
|
||||
id?: string;
|
||||
fromNumber?: string;
|
||||
toNumber?: string;
|
||||
recordingId?: string | null;
|
||||
direction?: string;
|
||||
reportDispositionType?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
duration?: string;
|
||||
extension?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DaftaresomaClient {
|
||||
private readonly logger = new Logger(DaftaresomaClient.name);
|
||||
|
||||
async outgoingCall(
|
||||
apiToken: string,
|
||||
input: DaftaresomaOutgoingCallInput,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/OutgoingCall`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from_number: input.fromNumber,
|
||||
to_number: input.toNumber,
|
||||
caller_extension: input.callerExtension,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
this.logger.warn(
|
||||
`OutgoingCall failed (${res.status}): ${text.slice(0, 300)}`,
|
||||
);
|
||||
throw new Error(
|
||||
text.trim()
|
||||
? `تماس خروجی دفتر شما ناموفق بود (${res.status}): ${text.slice(0, 200)}`
|
||||
: `تماس خروجی دفتر شما ناموفق بود (${res.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async findCallById(
|
||||
apiToken: string,
|
||||
callId: string,
|
||||
): Promise<DaftaresomaCallSearchRow | null> {
|
||||
const res = await fetch(`${BASE_URL}/CustomerCallSearch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id: callId, limit: 1, pagination: 1 }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
this.logger.warn(
|
||||
`CustomerCallSearch failed (${res.status}): ${text.slice(0, 200)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
data?: DaftaresomaCallSearchRow[];
|
||||
};
|
||||
const row = Array.isArray(json.data) ? json.data[0] : null;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async downloadRecording(
|
||||
apiToken: string,
|
||||
recordingId: string,
|
||||
): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
|
||||
const url = new URL(`${BASE_URL}/GetCallRecording`);
|
||||
url.searchParams.set('recordingId', recordingId);
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
Accept: '*/*',
|
||||
'User-Agent': 'MontactCRM-VoIP/1.0',
|
||||
},
|
||||
signal: AbortSignal.timeout(60000),
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`دانلود ضبط دفتر شما ناموفق بود (${res.status})${text ? `: ${text.slice(0, 160)}` : ''}`,
|
||||
);
|
||||
}
|
||||
const contentLength = Number(res.headers.get('content-length') || 0);
|
||||
if (contentLength > MAX_AUDIO_BYTES) {
|
||||
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
|
||||
}
|
||||
const buffer = await res.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_AUDIO_BYTES) {
|
||||
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
|
||||
}
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error('فایل ضبط خالی است');
|
||||
}
|
||||
const contentType =
|
||||
res.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/wav';
|
||||
const filename = `daftareshoma-${recordingId}.wav`;
|
||||
return { buffer, contentType, filename };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { VoipWebhookEvent } from '@mento/shared';
|
||||
|
||||
/** پیشوند ذخیرهشده در recordingUrl برای واکشی بعدی با توکن API */
|
||||
export const DAFTARESHOMA_RECORDING_PREFIX = 'daftareshoma-recording:';
|
||||
|
||||
export function daftareshomaRecordingRef(recordingId: string): string {
|
||||
return `${DAFTARESHOMA_RECORDING_PREFIX}${recordingId}`;
|
||||
}
|
||||
|
||||
export function parseDaftaresomaRecordingRef(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw.startsWith(DAFTARESHOMA_RECORDING_PREFIX)) return null;
|
||||
const id = raw.slice(DAFTARESHOMA_RECORDING_PREFIX.length).trim();
|
||||
return id || null;
|
||||
}
|
||||
|
||||
type Loose = Record<string, unknown>;
|
||||
|
||||
function str(v: unknown): string {
|
||||
return v == null ? '' : String(v).trim();
|
||||
}
|
||||
|
||||
function pick(obj: Loose, ...keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = str(obj[k]);
|
||||
if (v) return v;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseDurationSec(raw: unknown): number | undefined {
|
||||
if (raw == null || raw === '') return undefined;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
return Math.max(0, Math.round(raw));
|
||||
}
|
||||
const s = String(raw).trim();
|
||||
if (!s) return undefined;
|
||||
if (/^\d+(\.\d+)?$/.test(s)) return Math.max(0, Math.round(Number(s)));
|
||||
const parts = s.split(':').map((p) => Number(p));
|
||||
if (parts.some((n) => !Number.isFinite(n))) return undefined;
|
||||
if (parts.length === 3) {
|
||||
return Math.max(0, Math.round(parts[0]! * 3600 + parts[1]! * 60 + parts[2]!));
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return Math.max(0, Math.round(parts[0]! * 60 + parts[1]!));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* رویداد وبهوک دفتر شما را به VoipWebhookEvent تبدیل میکند.
|
||||
* اگر payload از قبل فرمت Montact باشد، همان را برمیگرداند.
|
||||
*/
|
||||
export function mapDaftaresomaWebhook(
|
||||
body: unknown,
|
||||
): VoipWebhookEvent | null {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const obj = body as Loose;
|
||||
|
||||
const legacyCallId = pick(obj, 'callId');
|
||||
if (legacyCallId && (obj.direction || obj.status || obj.from || obj.to)) {
|
||||
return body as VoipWebhookEvent;
|
||||
}
|
||||
|
||||
const eventType = pick(obj, 'EventType', 'eventType', 'event');
|
||||
const callId = pick(obj, 'CallId', 'callId', 'Id', 'id');
|
||||
if (!callId) return null;
|
||||
|
||||
const caller = pick(
|
||||
obj,
|
||||
'CallerNumber',
|
||||
'callerNumber',
|
||||
'from',
|
||||
'From',
|
||||
'fromNumber',
|
||||
);
|
||||
/** خط سازمانی که تماس روی آن آمده/رفته */
|
||||
const lineExtension =
|
||||
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
|
||||
undefined;
|
||||
/** مقصد انتقال / شماره طرف مقابل روی خط */
|
||||
const transferred = pick(
|
||||
obj,
|
||||
'TransferredToNumber',
|
||||
'transferredToNumber',
|
||||
);
|
||||
|
||||
const startedAt =
|
||||
pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined;
|
||||
const endedAt =
|
||||
pick(obj, 'EndDateTime', 'endDateTime', 'endedAt', 'DateTime') ||
|
||||
undefined;
|
||||
const durationSec = parseDurationSec(
|
||||
obj.DurationInSeconds ?? obj.durationInSeconds ?? obj.durationSec,
|
||||
);
|
||||
|
||||
const lower = eventType.toLowerCase();
|
||||
const isOutbound = lower.includes('outgoing') || lower.includes('outbound');
|
||||
const isStarted =
|
||||
lower.includes('started') ||
|
||||
lower.includes('ringing') ||
|
||||
lower.includes('transferstarted');
|
||||
const isEnded =
|
||||
lower.includes('ended') ||
|
||||
lower.includes('completed') ||
|
||||
lower.includes('hangup');
|
||||
|
||||
let status: string = 'completed';
|
||||
if (isStarted && !isEnded) {
|
||||
status = 'ringing';
|
||||
} else if (isEnded) {
|
||||
const result = obj.Result ?? obj.result;
|
||||
if (result === false || result === 'false' || result === 0) {
|
||||
status = durationSec && durationSec > 0 ? 'completed' : 'missed';
|
||||
} else if (result === true || result === 'true' || result === 1) {
|
||||
status = 'completed';
|
||||
} else {
|
||||
status = 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
const recordingId = pick(
|
||||
obj,
|
||||
'RecordingId',
|
||||
'recordingId',
|
||||
'RecordId',
|
||||
'recordId',
|
||||
);
|
||||
|
||||
// inbound: from=مشتری، to=همکار/مقصد انتقال یا خود خط
|
||||
// outbound: from=همکار، to=مشتری (TransferredToNumber)
|
||||
let from = caller || undefined;
|
||||
let to: string | undefined;
|
||||
if (isOutbound) {
|
||||
to = transferred || undefined;
|
||||
} else {
|
||||
to = transferred || lineExtension || undefined;
|
||||
}
|
||||
|
||||
const event: VoipWebhookEvent = {
|
||||
callId,
|
||||
direction: isOutbound ? 'outbound' : 'inbound',
|
||||
status,
|
||||
from,
|
||||
to,
|
||||
lineExtension,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
caller: from,
|
||||
callee: to,
|
||||
};
|
||||
if (recordingId) {
|
||||
event.recordingUrl = daftareshomaRecordingRef(recordingId);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
export function isDaftaresomaWebhookPayload(body: unknown): boolean {
|
||||
if (!body || typeof body !== 'object') return false;
|
||||
const obj = body as Loose;
|
||||
return Boolean(
|
||||
pick(obj, 'EventType', 'eventType') ||
|
||||
(pick(obj, 'CallId') && pick(obj, 'CallerNumber', 'callerNumber')),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
|
||||
@MaxLength(80)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['generic', 'daftareshoma'])
|
||||
provider?: 'generic' | 'daftareshoma';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
|
||||
@MaxLength(500)
|
||||
clickToCallUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
apiToken?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
fromNumber?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
callerExtension?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
|
||||
@MaxLength(500)
|
||||
clickToCallUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
apiToken?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
fromNumber?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
callerExtension?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -18,17 +18,31 @@ export class SttClientService {
|
||||
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
||||
}
|
||||
|
||||
const timeoutMs = Number(
|
||||
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
||||
);
|
||||
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
|
||||
return this.transcribeAudio(audio, timeoutMs);
|
||||
}
|
||||
|
||||
async transcribeAudio(
|
||||
audio: { buffer: ArrayBuffer; contentType: string; filename: string },
|
||||
timeoutMs?: number,
|
||||
): Promise<string> {
|
||||
if (!this.isEnabled()) {
|
||||
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
||||
}
|
||||
|
||||
const baseUrl = this.config.get<string>('STT_URL')?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
|
||||
}
|
||||
|
||||
const timeoutMs = Number(
|
||||
const waitMs = timeoutMs ?? Number(
|
||||
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
||||
);
|
||||
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
|
||||
|
||||
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
|
||||
const form = new FormData();
|
||||
const blob = new Blob([new Uint8Array(audio.buffer)], {
|
||||
type: audio.contentType,
|
||||
@@ -39,7 +53,7 @@ export class SttClientService {
|
||||
if (token) headers['X-STT-Token'] = token;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), waitMs);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
UpdateVoipConnectionDto,
|
||||
VoipClickToCallDto,
|
||||
} from './dto';
|
||||
import {
|
||||
isDaftaresomaWebhookPayload,
|
||||
mapDaftaresomaWebhook,
|
||||
} from './daftareshoma-webhook';
|
||||
|
||||
@Controller('integrations/voip')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@@ -78,8 +82,16 @@ export class VoipController {
|
||||
recent(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('direction') direction?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('lineExtension') lineExtension?: string,
|
||||
) {
|
||||
return this.calls.listRecent(user.organizationId, connectionId);
|
||||
return this.calls.listRecent(user.organizationId, {
|
||||
connectionId,
|
||||
direction,
|
||||
status,
|
||||
lineExtension,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('click-to-call')
|
||||
@@ -129,11 +141,21 @@ export class VoipWebhookController {
|
||||
if (!ok) throw new UnauthorizedException('امضای وبهوک نامعتبر است');
|
||||
}
|
||||
|
||||
if (!body?.callId) {
|
||||
let event: VoipWebhookEvent | null = null;
|
||||
if (
|
||||
row.provider === 'daftareshoma' ||
|
||||
isDaftaresomaWebhookPayload(body)
|
||||
) {
|
||||
event = mapDaftaresomaWebhook(body);
|
||||
} else if (body?.callId) {
|
||||
event = body;
|
||||
}
|
||||
|
||||
if (!event?.callId) {
|
||||
return { ok: false, error: 'callId required' };
|
||||
}
|
||||
|
||||
const result = await this.calls.ingestWebhook(connectionId, body);
|
||||
const result = await this.calls.ingestWebhook(connectionId, event);
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
||||
import { CallSummaryService } from './call-summary.service';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
import { VoipCallsService } from './calls.service';
|
||||
import { DaftaresomaClient } from './daftareshoma-client';
|
||||
import { SttClientService } from './stt-client.service';
|
||||
import { VoipController, VoipWebhookController } from './voip.controller';
|
||||
|
||||
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
|
||||
TahesabCryptoService,
|
||||
VoipConnectionsService,
|
||||
VoipCallsService,
|
||||
DaftaresomaClient,
|
||||
SttClientService,
|
||||
CallSummaryService,
|
||||
],
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
WooCommerceSyncResultDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { WooConnectionsService } from './connections.service';
|
||||
import type { WooOrder } from './woo-client';
|
||||
|
||||
@@ -374,7 +378,8 @@ export class WooSyncService {
|
||||
): Promise<string | null> {
|
||||
const billing = order.billing ?? {};
|
||||
const email = normalizeEmail(billing.email ?? null);
|
||||
const mobile = normalizePhone(billing.phone ?? null);
|
||||
const mobile = storedPhoneNumber(billing.phone ?? null);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const firstName = (billing.first_name ?? '').trim() || 'مشتری';
|
||||
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
|
||||
|
||||
@@ -387,7 +392,9 @@ export class WooSyncService {
|
||||
organizationId,
|
||||
OR: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(mobile ? [{ mobile }, { phone: mobile }] : []),
|
||||
...(mobileKeys.length
|
||||
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
|
||||
@@ -32,6 +32,8 @@ const MAP_TO = [
|
||||
'lead.trade',
|
||||
'lead.notes',
|
||||
'lead.source',
|
||||
'lead.gender',
|
||||
'person.gender',
|
||||
'notes',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
@@ -13,15 +13,20 @@ import type {
|
||||
SubmitLeadFormResponse,
|
||||
UpdateLeadFormRequest,
|
||||
} from '@mento/shared';
|
||||
import { isGenderQuestionLabel, parsePersonGenderAnswer } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../contacts/normalize';
|
||||
import { LeadStagesService } from '../leads/lead-stages.service';
|
||||
|
||||
@@ -83,6 +88,7 @@ export class LeadFormsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly storage: StorageQuotaService,
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly leadStages: LeadStagesService,
|
||||
@@ -210,7 +216,12 @@ export class LeadFormsService {
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.findInOrg(organizationId, id);
|
||||
const row = await this.findInOrg(organizationId, id);
|
||||
if (row.coverStorageKey) {
|
||||
const bytes = await this.files.sizeOf(row.coverStorageKey);
|
||||
await this.files.remove(row.coverStorageKey);
|
||||
if (bytes) await this.storage.addLocal(organizationId, -bytes);
|
||||
}
|
||||
await this.prisma.leadForm.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -391,6 +402,7 @@ export class LeadFormsService {
|
||||
sourceId: form.id,
|
||||
sourceType: 'lead_form',
|
||||
leadId,
|
||||
syncLeadAssignee: !ownerId && leadCreated && !!leadId,
|
||||
mobile: mapped.mobile ?? mapped.phone ?? null,
|
||||
formName: form.name,
|
||||
formValues: values,
|
||||
@@ -449,7 +461,10 @@ export class LeadFormsService {
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: form.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
OR: [
|
||||
{ mobile: { in: phoneIdentityKeys(mobile) } },
|
||||
{ phone: { in: phoneIdentityKeys(mobile) } },
|
||||
],
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
})
|
||||
@@ -547,6 +562,7 @@ export class LeadFormsService {
|
||||
trade: emptyToNull(mapped.trade) ?? existingLead.trade,
|
||||
source: source ?? existingLead.source,
|
||||
notes: emptyToNull(mapped.notes) ?? existingLead.notes,
|
||||
...(mapped.gender ? { gender: mapped.gender } : {}),
|
||||
assigneeId: existingLead.assigneeId ?? assigneeId,
|
||||
lastContactAt: new Date(),
|
||||
},
|
||||
@@ -595,6 +611,7 @@ export class LeadFormsService {
|
||||
trade: emptyToNull(mapped.trade),
|
||||
source,
|
||||
notes: emptyToNull(mapped.notes),
|
||||
gender: mapped.gender ?? 'none',
|
||||
stageId,
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
assigneeId,
|
||||
@@ -630,13 +647,16 @@ export class LeadFormsService {
|
||||
ownerId: string,
|
||||
): Promise<string> {
|
||||
const email = normalizeEmail(mapped.email);
|
||||
const mobile = normalizePhone(mapped.mobile);
|
||||
const phone = normalizePhone(mapped.phone);
|
||||
const mobile = storedPhoneNumber(mapped.mobile);
|
||||
const phone = storedPhoneNumber(mapped.phone);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
const existing = or.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: { organizationId, OR: or },
|
||||
@@ -654,6 +674,7 @@ export class LeadFormsService {
|
||||
phone: phone ?? existing.phone,
|
||||
position: emptyToNull(mapped.position) ?? existing.position,
|
||||
notes: emptyToNull(mapped.notes) ?? existing.notes,
|
||||
...(mapped.gender ? { gender: mapped.gender } : {}),
|
||||
ownerId: existing.ownerId ?? ownerId,
|
||||
},
|
||||
});
|
||||
@@ -670,6 +691,7 @@ export class LeadFormsService {
|
||||
phone,
|
||||
position: emptyToNull(mapped.position),
|
||||
notes: emptyToNull(mapped.notes),
|
||||
gender: mapped.gender ?? 'none',
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
@@ -829,6 +851,9 @@ export class LeadFormsService {
|
||||
throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد');
|
||||
}
|
||||
const key = `lead-forms/${id}/cover.${ext}`;
|
||||
const prevBytes = row.coverStorageKey ? await this.files.sizeOf(row.coverStorageKey) : 0;
|
||||
const delta = file.buffer.length - prevBytes;
|
||||
if (delta > 0) await this.storage.assertCanAdd(organizationId, delta);
|
||||
await this.files.save(key, file.buffer);
|
||||
if (row.coverStorageKey && row.coverStorageKey !== key) {
|
||||
await this.files.remove(row.coverStorageKey);
|
||||
@@ -837,12 +862,17 @@ export class LeadFormsService {
|
||||
where: { id },
|
||||
data: { coverStorageKey: key },
|
||||
});
|
||||
if (delta) await this.storage.addLocal(organizationId, delta);
|
||||
return this.get(organizationId, id);
|
||||
}
|
||||
|
||||
async removeCover(organizationId: string, id: string) {
|
||||
const row = await this.findInOrg(organizationId, id);
|
||||
if (row.coverStorageKey) await this.files.remove(row.coverStorageKey);
|
||||
if (row.coverStorageKey) {
|
||||
const bytes = await this.files.sizeOf(row.coverStorageKey);
|
||||
await this.files.remove(row.coverStorageKey);
|
||||
if (bytes) await this.storage.addLocal(organizationId, -bytes);
|
||||
}
|
||||
await this.prisma.leadForm.update({
|
||||
where: { id },
|
||||
data: { coverStorageKey: null },
|
||||
@@ -925,6 +955,7 @@ type MappedValues = {
|
||||
region?: string;
|
||||
trade?: string;
|
||||
leadSource?: string;
|
||||
gender?: 'male' | 'female' | 'none';
|
||||
};
|
||||
|
||||
function mapValues(
|
||||
@@ -945,6 +976,15 @@ function mapValues(
|
||||
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
|
||||
}
|
||||
if (!raw) continue;
|
||||
if (
|
||||
field.mapTo === 'lead.gender' ||
|
||||
field.mapTo === 'person.gender' ||
|
||||
isGenderQuestionLabel(field.label)
|
||||
) {
|
||||
const gender = parsePersonGenderAnswer(raw);
|
||||
if (gender) mapped.gender = gender;
|
||||
continue;
|
||||
}
|
||||
switch (field.mapTo) {
|
||||
case 'person.fullName': {
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
@@ -1068,7 +1108,8 @@ function collectExtras(
|
||||
for (const field of fields) {
|
||||
if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue;
|
||||
const raw = (values?.[field.key] ?? '').toString().trim();
|
||||
if (raw) lines.push(`${field.label}: ${raw}`);
|
||||
if (!raw || (isGenderQuestionLabel(field.label) && parsePersonGenderAnswer(raw))) continue;
|
||||
lines.push(`${field.label}: ${raw}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1083,13 +1124,12 @@ function slugKey(label: string): string {
|
||||
}
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function normalizeDuplicateMode(raw?: string | null): string {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { PERSON_GENDERS } from '@mento/shared';
|
||||
|
||||
export class LeadSmsDto {
|
||||
@IsString()
|
||||
@@ -26,6 +27,8 @@ export class CreateLeadDto {
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional() @IsString() contactName?: string;
|
||||
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
|
||||
@IsOptional() @IsBoolean() showGenderInName?: boolean;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() region?: string;
|
||||
@@ -47,6 +50,8 @@ export class UpdateLeadDto {
|
||||
@IsOptional() @IsString() @MinLength(1) title?: string;
|
||||
@IsOptional() @IsString() @MinLength(5) mobile?: string;
|
||||
@IsOptional() @IsString() contactName?: string | null;
|
||||
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
|
||||
@IsOptional() @IsBoolean() showGenderInName?: boolean;
|
||||
@IsOptional() @IsString() phone?: string | null;
|
||||
@IsOptional() @IsString() city?: string | null;
|
||||
@IsOptional() @IsString() region?: string | null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { LeadStageDto } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const DEFAULT_STAGES: Array<{
|
||||
@@ -74,20 +75,24 @@ export class LeadStagesService {
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const row = await this.prisma.leadStage.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: dto.name.trim(),
|
||||
color: dto.color?.trim() || 'teal',
|
||||
showOnBoard: dto.showOnBoard !== false,
|
||||
isConverted: Boolean(dto.isConverted),
|
||||
isLost: Boolean(dto.isLost),
|
||||
isUnqualified: Boolean(dto.isUnqualified),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
try {
|
||||
const row = await this.prisma.leadStage.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: this.cleanName(dto.name),
|
||||
color: dto.color?.trim() || 'teal',
|
||||
showOnBoard: dto.showOnBoard !== false,
|
||||
isConverted: Boolean(dto.isConverted),
|
||||
isLost: Boolean(dto.isLost),
|
||||
isUnqualified: Boolean(dto.isUnqualified),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
this.rethrow(e);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -103,10 +108,11 @@ export class LeadStagesService {
|
||||
position?: number;
|
||||
},
|
||||
): Promise<LeadStageDto> {
|
||||
try {
|
||||
await this.prisma.leadStage.updateMany({
|
||||
where: { id, organizationId },
|
||||
data: {
|
||||
...(dto.name != null ? { name: dto.name.trim() } : {}),
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.color != null ? { color: dto.color } : {}),
|
||||
...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}),
|
||||
...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}),
|
||||
@@ -122,6 +128,9 @@ export class LeadStagesService {
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
this.rethrow(e);
|
||||
}
|
||||
}
|
||||
|
||||
async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> {
|
||||
@@ -143,11 +152,27 @@ export class LeadStagesService {
|
||||
});
|
||||
if (!stage) return;
|
||||
if (stage._count.leads > 0) {
|
||||
throw new Error('مرحلهای که لید دارد قابل حذف نیست');
|
||||
throw new BadRequestException(
|
||||
'این ستون هنوز لید دارد و حذف نمیشود. لیدهای فعال و بایگانی را اول به ستون دیگری ببرید.',
|
||||
);
|
||||
}
|
||||
await this.prisma.leadStage.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private cleanName(raw: string): string {
|
||||
const name = raw?.trim() ?? '';
|
||||
if (!name) throw new BadRequestException('نام ستون لازم است');
|
||||
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
|
||||
return name;
|
||||
}
|
||||
|
||||
private rethrow(e: unknown): never {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('ستونی با این نام وجود دارد');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -107,6 +107,7 @@ export class LeadsController {
|
||||
await this.stages.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
if (e instanceof BadRequestException) throw e;
|
||||
throw new BadRequestException(
|
||||
e instanceof Error ? e.message : 'حذف مرحله ناموفق بود',
|
||||
);
|
||||
@@ -245,7 +246,11 @@ export class LeadsController {
|
||||
@Query('source') source?: string,
|
||||
@Query('unassigned') unassigned?: string,
|
||||
@Query('archived') archived?: string,
|
||||
@Query('converted') converted?: string,
|
||||
@Query('hasMobile') hasMobile?: string,
|
||||
@Query('followUp') followUp?: string,
|
||||
@Query('createdFrom') createdFrom?: string,
|
||||
@Query('createdTo') createdTo?: string,
|
||||
) {
|
||||
return this.leads.listIds(user, {
|
||||
q,
|
||||
@@ -257,7 +262,11 @@ export class LeadsController {
|
||||
source,
|
||||
unassigned: unassigned === '1' || unassigned === 'true',
|
||||
archived: archived === '1' || archived === 'true',
|
||||
converted: converted === '1' || converted === 'true',
|
||||
hasMobile: hasMobile === '1' || hasMobile === 'true',
|
||||
followUp,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,8 +284,12 @@ export class LeadsController {
|
||||
@Query('source') source?: string,
|
||||
@Query('unassigned') unassigned?: string,
|
||||
@Query('archived') archived?: string,
|
||||
@Query('converted') converted?: string,
|
||||
@Query('sort') sort?: string,
|
||||
@Query('hasMobile') hasMobile?: string,
|
||||
@Query('followUp') followUp?: string,
|
||||
@Query('createdFrom') createdFrom?: string,
|
||||
@Query('createdTo') createdTo?: string,
|
||||
) {
|
||||
return this.leads.list(user, {
|
||||
q,
|
||||
@@ -290,8 +303,12 @@ export class LeadsController {
|
||||
source,
|
||||
unassigned: unassigned === '1' || unassigned === 'true',
|
||||
archived: archived === '1' || archived === 'true',
|
||||
converted: converted === '1' || converted === 'true',
|
||||
sort,
|
||||
hasMobile: hasMobile === '1' || hasMobile === 'true',
|
||||
followUp,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,8 +318,6 @@ export class LeadsController {
|
||||
}
|
||||
|
||||
@Post('assign')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) {
|
||||
return this.leads
|
||||
.assign(user, dto.leadIds, dto.assigneeId)
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { FILTERED_IDS_MAX } from '@mento/shared';
|
||||
import { applyLeadGenderName, applyPersonGenderTitle } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
@@ -30,16 +31,30 @@ import {
|
||||
elevated,
|
||||
forScope,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
import {
|
||||
asAddresses,
|
||||
collectAddresses,
|
||||
toJsonAddresses,
|
||||
} from '../common/custom-fields';
|
||||
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
} from '../contacts/normalize';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
addDays,
|
||||
isDayKey,
|
||||
tehranDayKey,
|
||||
tehranDayStart,
|
||||
} from '../routines/routine-dates';
|
||||
|
||||
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const LOGO_EXT: Record<string, string> = {
|
||||
@@ -51,13 +66,20 @@ const LOGO_EXT: Record<string, string> = {
|
||||
};
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput {
|
||||
const keys = phoneIdentityKeys(mobile);
|
||||
return {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
|
||||
};
|
||||
}
|
||||
|
||||
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
|
||||
@@ -90,6 +112,7 @@ export class LeadsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly stages: LeadStagesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly storage: StorageQuotaService,
|
||||
private readonly game: GamificationService,
|
||||
private readonly sms: SmsGatewayService,
|
||||
private readonly outboundSms: OutboundSmsLogService,
|
||||
@@ -125,7 +148,11 @@ export class LeadsService {
|
||||
source?: string;
|
||||
unassigned?: boolean;
|
||||
archived?: boolean;
|
||||
converted?: boolean;
|
||||
hasMobile?: boolean;
|
||||
followUp?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
},
|
||||
): Prisma.LeadWhereInput {
|
||||
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
|
||||
@@ -144,22 +171,77 @@ export class LeadsService {
|
||||
if (opts.hasMobile) {
|
||||
and.push({ mobile: { not: '' } });
|
||||
}
|
||||
const follow = this.followUpWhere(opts.followUp);
|
||||
if (follow) and.push(follow);
|
||||
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
|
||||
if (createdRange) and.push(createdRange);
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...leadAccessWhere(actor),
|
||||
archivedAt: opts.archived ? { not: null } : null,
|
||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
|
||||
...(opts.unassigned ? { assigneeId: null } : {}),
|
||||
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
||||
...(opts.region
|
||||
? { region: { contains: opts.region, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
||||
...(opts.source
|
||||
? { source: { contains: opts.source, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...leadAccessWhere(actor),
|
||||
archivedAt: opts.archived ? { not: null } : null,
|
||||
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
|
||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
||||
...(opts.region
|
||||
? { region: { contains: opts.region, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
||||
...(opts.source
|
||||
? { source: { contains: opts.source, mode: 'insensitive' } }
|
||||
: {}),
|
||||
},
|
||||
...(opts.unassigned ? [{ assigneeId: null }] : []),
|
||||
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
|
||||
...and,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** مرز روز به وقت تهران برای فیلترهای «امروز». */
|
||||
private tehranDayBounds(day = tehranDayKey()) {
|
||||
const start = tehranDayStart(day);
|
||||
const end = tehranDayStart(addDays(day, 1));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
private followUpWhere(followUp?: string): Prisma.LeadWhereInput | null {
|
||||
if (!followUp) return null;
|
||||
const { start, end } = this.tehranDayBounds();
|
||||
switch (followUp) {
|
||||
case 'due_today':
|
||||
return { nextFollowUpAt: { gte: start, lt: end } };
|
||||
case 'overdue':
|
||||
return { nextFollowUpAt: { not: null, lt: start } };
|
||||
case 'called_today':
|
||||
return { lastContactAt: { gte: start, lt: end } };
|
||||
case 'missed_today':
|
||||
return {
|
||||
nextFollowUpAt: { not: null, lt: end },
|
||||
OR: [{ lastContactAt: null }, { lastContactAt: { lt: start } }],
|
||||
};
|
||||
case 'no_follow_up':
|
||||
return { nextFollowUpAt: null };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private createdAtWhere(
|
||||
createdFrom?: string,
|
||||
createdTo?: string,
|
||||
): Prisma.LeadWhereInput | null {
|
||||
const from = isDayKey(createdFrom) ? tehranDayStart(createdFrom) : null;
|
||||
const to = isDayKey(createdTo)
|
||||
? tehranDayStart(addDays(createdTo, 1))
|
||||
: null;
|
||||
if (!from && !to) return null;
|
||||
return {
|
||||
createdAt: {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lt: to } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,8 +259,12 @@ export class LeadsService {
|
||||
source?: string;
|
||||
unassigned?: boolean;
|
||||
archived?: boolean;
|
||||
converted?: boolean;
|
||||
sort?: string;
|
||||
hasMobile?: boolean;
|
||||
followUp?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
},
|
||||
): Promise<PaginatedResponse<LeadDto>> {
|
||||
await this.stages.ensureDefaults(actor.organizationId);
|
||||
@@ -226,7 +312,11 @@ export class LeadsService {
|
||||
source?: string;
|
||||
unassigned?: boolean;
|
||||
archived?: boolean;
|
||||
converted?: boolean;
|
||||
hasMobile?: boolean;
|
||||
followUp?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
},
|
||||
max = FILTERED_IDS_MAX,
|
||||
): Promise<FilteredIdsResponse> {
|
||||
@@ -318,10 +408,7 @@ export class LeadsService {
|
||||
};
|
||||
}
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
},
|
||||
where: personWithMobile(actor.organizationId, mobile),
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (person) {
|
||||
@@ -341,6 +428,8 @@ export class LeadsService {
|
||||
title: string;
|
||||
mobile: string;
|
||||
contactName?: string | null;
|
||||
gender?: string | null;
|
||||
showGenderInName?: boolean | null;
|
||||
phone?: string | null;
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
@@ -374,19 +463,31 @@ export class LeadsService {
|
||||
const stageId = dto.stageId || stages[0]?.id;
|
||||
if (!stageId) throw new BadRequestException('مرحلهای تعریف نشده');
|
||||
|
||||
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'leads');
|
||||
let assigneeId: string | null = null;
|
||||
if (elevated(actor.role) && dto.assigneeId) {
|
||||
if (allowOthers && dto.assigneeId) {
|
||||
assigneeId = dto.assigneeId;
|
||||
} else if (!elevated(actor.role)) {
|
||||
} else if (!allowOthers) {
|
||||
assigneeId = actor.userId;
|
||||
}
|
||||
|
||||
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
||||
const named = applyLeadGenderName({
|
||||
title: dto.title,
|
||||
contactName: dto.contactName,
|
||||
gender:
|
||||
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
|
||||
? dto.gender
|
||||
: undefined,
|
||||
showGenderInName: dto.showGenderInName,
|
||||
});
|
||||
const row = await this.prisma.lead.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title: dto.title.trim(),
|
||||
contactName: emptyToNull(dto.contactName),
|
||||
title: named.title,
|
||||
contactName: named.contactName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile,
|
||||
phone: emptyToNull(dto.phone),
|
||||
city: emptyToNull(dto.city),
|
||||
@@ -461,12 +562,41 @@ export class LeadsService {
|
||||
}
|
||||
}
|
||||
|
||||
const touchName =
|
||||
typeof dto.title === 'string' ||
|
||||
dto.contactName !== undefined ||
|
||||
dto.gender !== undefined ||
|
||||
dto.showGenderInName !== undefined;
|
||||
const named = touchName
|
||||
? applyLeadGenderName({
|
||||
title: typeof dto.title === 'string' ? dto.title : existing.title,
|
||||
contactName:
|
||||
dto.contactName !== undefined
|
||||
? (dto.contactName as string | null)
|
||||
: existing.contactName,
|
||||
gender:
|
||||
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
|
||||
? dto.gender
|
||||
: existing.gender === 'male' || existing.gender === 'female'
|
||||
? existing.gender
|
||||
: 'none',
|
||||
showGenderInName:
|
||||
typeof dto.showGenderInName === 'boolean'
|
||||
? dto.showGenderInName
|
||||
: existing.showGenderInName,
|
||||
})
|
||||
: null;
|
||||
|
||||
const data: Prisma.LeadUpdateInput = {
|
||||
...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}),
|
||||
mobile,
|
||||
...(dto.contactName !== undefined
|
||||
? { contactName: emptyToNull(dto.contactName as string) }
|
||||
...(named
|
||||
? {
|
||||
title: named.title,
|
||||
contactName: named.contactName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
mobile,
|
||||
...(dto.phone !== undefined
|
||||
? { phone: emptyToNull(dto.phone as string) }
|
||||
: {}),
|
||||
@@ -525,8 +655,9 @@ export class LeadsService {
|
||||
if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) {
|
||||
data.stage = { connect: { id: dto.stageId } };
|
||||
}
|
||||
if (elevated(actor.role) && dto.assigneeId !== undefined) {
|
||||
if (dto.assigneeId !== undefined) {
|
||||
const aid = dto.assigneeId ? String(dto.assigneeId) : null;
|
||||
await assertAssigneeSet(this.prisma, actor, 'leads', [aid], [existing.assigneeId]);
|
||||
data.assignee = aid
|
||||
? { connect: { id: aid } }
|
||||
: { disconnect: true };
|
||||
@@ -565,24 +696,43 @@ export class LeadsService {
|
||||
});
|
||||
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
|
||||
|
||||
let placedIds: string[] | null = null;
|
||||
let nextPosition = position ?? 0;
|
||||
if (position != null) {
|
||||
const rows = await this.prisma.lead.findMany({
|
||||
where: { organizationId: actor.organizationId, stageId, archivedAt: null },
|
||||
select: { id: true },
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
placedIds = placeId(currentIds, id, position);
|
||||
nextPosition = Math.max(0, placedIds.indexOf(id));
|
||||
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||
}
|
||||
|
||||
const row = await this.prisma.lead.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stageId,
|
||||
position: position ?? 0,
|
||||
...(stage.isConverted
|
||||
? {}
|
||||
: {}),
|
||||
position: nextPosition,
|
||||
},
|
||||
include: leadInclude,
|
||||
});
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
if (placedIds) {
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placedIds,
|
||||
(leadId, leadPosition) =>
|
||||
this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
|
||||
);
|
||||
}
|
||||
if (before.stageId !== stageId) {
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${stageId}`,
|
||||
facts: { stageName: stage.name, source: row.source },
|
||||
@@ -596,9 +746,7 @@ export class LeadsService {
|
||||
leadIds: string[],
|
||||
assigneeId: string,
|
||||
): Promise<number> {
|
||||
if (!elevated(actor.role)) {
|
||||
throw new BadRequestException('فقط مدیر میتواند تخصیص دهد');
|
||||
}
|
||||
await assertAssigneeSet(this.prisma, actor, 'leads', [assigneeId]);
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: assigneeId,
|
||||
@@ -764,14 +912,14 @@ export class LeadsService {
|
||||
? `تماس ثبت شد — ${dto.outcome}`
|
||||
: 'تماس ثبت شد';
|
||||
await this.addEvent(actor, id, 'call', title, dto.body ?? null);
|
||||
const followAt = this.parseFollowUpAt(dto.nextFollowUpAt);
|
||||
if (followAt) {
|
||||
await this.createLeadFollowUpActivity(actor, before, followAt, dto.body);
|
||||
}
|
||||
const data: Prisma.LeadUpdateInput = {
|
||||
lastContactAt: new Date(),
|
||||
...(dto.nextFollowUpAt !== undefined
|
||||
? {
|
||||
nextFollowUpAt: dto.nextFollowUpAt
|
||||
? new Date(dto.nextFollowUpAt)
|
||||
: null,
|
||||
}
|
||||
? { nextFollowUpAt: followAt }
|
||||
: {}),
|
||||
};
|
||||
if (dto.stageId) data.stage = { connect: { id: dto.stageId } };
|
||||
@@ -810,8 +958,10 @@ export class LeadsService {
|
||||
nextFollowUpAt: string,
|
||||
body?: string,
|
||||
): Promise<LeadDto> {
|
||||
await this.findAccessible(actor, id);
|
||||
const at = new Date(nextFollowUpAt);
|
||||
const lead = await this.findAccessible(actor, id);
|
||||
const at = this.parseFollowUpAt(nextFollowUpAt);
|
||||
if (!at) throw new BadRequestException('زمان پیگیری نامعتبر است');
|
||||
await this.createLeadFollowUpActivity(actor, lead, at, body);
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
@@ -832,6 +982,39 @@ export class LeadsService {
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
/** پیگیری آینده باید خودش یک فعالیت باز و متصل به لید باشد، نه فقط یک تاریخ. */
|
||||
private parseFollowUpAt(value?: string | null): Date | null {
|
||||
if (!value) return null;
|
||||
const at = new Date(value);
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
throw new BadRequestException('زمان پیگیری نامعتبر است');
|
||||
}
|
||||
return at;
|
||||
}
|
||||
|
||||
private async createLeadFollowUpActivity(
|
||||
actor: Actor,
|
||||
lead: { id: string; title: string; assigneeId: string | null },
|
||||
dueAt: Date,
|
||||
body?: string | null,
|
||||
) {
|
||||
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'activities');
|
||||
const assigneeId = allowOthers && lead.assigneeId ? lead.assigneeId : actor.userId;
|
||||
await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
type: 'task',
|
||||
title: `پیگیری: ${lead.title}`.slice(0, 200),
|
||||
description: body?.trim() || null,
|
||||
dueAt,
|
||||
leadId: lead.id,
|
||||
assigneeId,
|
||||
assignees: { connect: [{ id: assigneeId }] },
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async archive(actor: Actor, ids: string[]): Promise<number> {
|
||||
if (!elevated(actor.role) && ids.length > 1) {
|
||||
throw new BadRequestException('حذف گروهی فقط برای مدیر');
|
||||
@@ -865,10 +1048,7 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
|
||||
},
|
||||
where: personWithMobile(actor.organizationId, lead.mobile),
|
||||
});
|
||||
let createdPerson = false;
|
||||
|
||||
@@ -877,14 +1057,25 @@ export class LeadsService {
|
||||
? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim()
|
||||
: (lead.contactName || lead.title).trim();
|
||||
const nameParts = rawName.split(/\s+/).filter(Boolean);
|
||||
const firstName = nameParts[0] || lead.title;
|
||||
const lastName = nameParts.slice(1).join(' ') || '—';
|
||||
const bareFirst = nameParts[0] || lead.title;
|
||||
const bareLast = nameParts.slice(1).join(' ');
|
||||
const named = applyPersonGenderTitle({
|
||||
firstName: bareFirst,
|
||||
lastName: bareLast,
|
||||
gender:
|
||||
lead.gender === 'male' || lead.gender === 'female' || lead.gender === 'none'
|
||||
? lead.gender
|
||||
: 'none',
|
||||
showGenderInName: lead.showGenderInName,
|
||||
});
|
||||
person = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
firstName,
|
||||
lastName,
|
||||
mobile: lead.mobile,
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
|
||||
phone: lead.phone,
|
||||
instagram: lead.instagram,
|
||||
birthDate: lead.birthDate,
|
||||
@@ -979,8 +1170,10 @@ export class LeadsService {
|
||||
const stages = await this.stages.list(actor.organizationId);
|
||||
const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id);
|
||||
const lostIds = stages.filter((s) => s.isLost).map((s) => s.id);
|
||||
const interested = stages.find((s) => s.name.includes('علاقهمند'));
|
||||
const newStage = stages.find((s) => s.name === 'جدید');
|
||||
// همان مقصد فرم و ورود از اکسل: اولین ستون باز، مستقل از اسم «جدید»
|
||||
const newStage = stages.find(
|
||||
(s) => !s.isConverted && !s.isLost && !s.isUnqualified,
|
||||
);
|
||||
|
||||
const [
|
||||
total,
|
||||
@@ -988,7 +1181,6 @@ export class LeadsService {
|
||||
unassigned,
|
||||
converted,
|
||||
lost,
|
||||
interestedCount,
|
||||
withFollowUp,
|
||||
callsLogged,
|
||||
byAssigneeRaw,
|
||||
@@ -997,7 +1189,7 @@ export class LeadsService {
|
||||
newStage
|
||||
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
|
||||
: Promise.resolve(0),
|
||||
this.prisma.lead.count({ where: { ...base, assigneeId: null } }),
|
||||
this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
|
||||
convertedIds.length
|
||||
? this.prisma.lead.count({
|
||||
where: { ...base, stageId: { in: convertedIds } },
|
||||
@@ -1008,11 +1200,6 @@ export class LeadsService {
|
||||
where: { ...base, stageId: { in: lostIds } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
interested
|
||||
? this.prisma.lead.count({
|
||||
where: { ...base, stageId: interested.id },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
this.prisma.lead.count({
|
||||
where: { ...base, nextFollowUpAt: { not: null } },
|
||||
}),
|
||||
@@ -1055,13 +1242,102 @@ export class LeadsService {
|
||||
.sort((a, b) => b.count - a.count),
|
||||
callsLogged,
|
||||
withFollowUp,
|
||||
interested: interestedCount,
|
||||
interested: 0,
|
||||
converted,
|
||||
lost,
|
||||
conversionRate: Math.round((converted / denom) * 1000) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
async createFromExternal(input: {
|
||||
organizationId: string;
|
||||
siteName: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
mobile: string;
|
||||
storeName?: string | null;
|
||||
source?: string | null;
|
||||
}): Promise<{
|
||||
ok: true;
|
||||
created: boolean;
|
||||
duplicate: boolean;
|
||||
leadId: string | null;
|
||||
}> {
|
||||
const mobile = mobileKey(input.mobile);
|
||||
if (!mobile || !/^09\d{9}$/.test(mobile)) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const contactName =
|
||||
`${input.firstName?.trim() ?? ''} ${input.lastName?.trim() ?? ''}`.trim() || null;
|
||||
const storeName = input.storeName?.trim() ?? '';
|
||||
const source = emptyToNull(input.source);
|
||||
const title = storeName || contactName || mobile;
|
||||
const siteName = input.siteName.trim() || 'سایت بیرونی';
|
||||
|
||||
const existing = await this.prisma.lead.findFirst({
|
||||
where: { organizationId: input.organizationId, mobile, archivedAt: null },
|
||||
select: { id: true, source: true, contactName: true },
|
||||
});
|
||||
if (existing) {
|
||||
const patch: Prisma.LeadUpdateInput = {};
|
||||
if (!existing.source && source) patch.source = source;
|
||||
if (!existing.contactName && contactName) patch.contactName = contactName;
|
||||
if (Object.keys(patch).length) {
|
||||
await this.prisma.lead.update({ where: { id: existing.id }, data: patch });
|
||||
}
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
leadId: existing.id,
|
||||
type: 'note',
|
||||
title: 'درخواست دوباره از سایت',
|
||||
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
},
|
||||
});
|
||||
return { ok: true, created: false, duplicate: true, leadId: existing.id };
|
||||
}
|
||||
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: personWithMobile(input.organizationId, mobile),
|
||||
select: { id: true },
|
||||
});
|
||||
if (person) {
|
||||
return { ok: true, created: false, duplicate: true, leadId: null };
|
||||
}
|
||||
|
||||
await this.stages.ensureDefaults(input.organizationId);
|
||||
const stages = await this.stages.list(input.organizationId);
|
||||
const stageId = stages[0]?.id;
|
||||
if (!stageId) throw new BadRequestException('مرحلهای تعریف نشده');
|
||||
|
||||
const lead = await this.prisma.lead.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
title,
|
||||
contactName,
|
||||
mobile,
|
||||
source,
|
||||
notes: `ثبت از سایت: ${siteName}`,
|
||||
stageId,
|
||||
},
|
||||
});
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
leadId: lead.id,
|
||||
type: 'created',
|
||||
title: 'لید از سایت ثبت شد',
|
||||
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
},
|
||||
});
|
||||
return { ok: true, created: true, duplicate: false, leadId: lead.id };
|
||||
}
|
||||
|
||||
async createFromImportRow(
|
||||
organizationId: string,
|
||||
createdById: string | null,
|
||||
@@ -1104,10 +1380,7 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
const existingPerson = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
},
|
||||
where: personWithMobile(organizationId, mobile),
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingPerson) {
|
||||
@@ -1206,6 +1479,8 @@ export class LeadsService {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
contactName: row.contactName,
|
||||
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
|
||||
showGenderInName: row.showGenderInName,
|
||||
mobile: row.mobile,
|
||||
phone: row.phone,
|
||||
city: row.city,
|
||||
@@ -1256,6 +1531,9 @@ export class LeadsService {
|
||||
const storageKey = path
|
||||
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
|
||||
const delta = file.size - prevBytes;
|
||||
if (delta > 0) await this.storage.assertCanAdd(actor.organizationId, delta);
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
if (prevKey && prevKey !== storageKey) {
|
||||
await this.files.remove(prevKey);
|
||||
@@ -1266,13 +1544,16 @@ export class LeadsService {
|
||||
include: leadInclude,
|
||||
});
|
||||
await this.addEvent(actor, id, 'note', 'لوگو بهروز شد', null);
|
||||
if (delta) await this.storage.addLocal(actor.organizationId, delta);
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
|
||||
const existing = await this.findAccessible(actor, id);
|
||||
if (existing.logoStorageKey) {
|
||||
const bytes = await this.files.sizeOf(existing.logoStorageKey);
|
||||
await this.files.remove(existing.logoStorageKey);
|
||||
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
|
||||
}
|
||||
const row = await this.prisma.lead.update({
|
||||
where: { id },
|
||||
@@ -1291,11 +1572,16 @@ export class LeadsService {
|
||||
const mobile = lead.mobile || lead.phone;
|
||||
if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.');
|
||||
const name = (lead.contactName || lead.title).trim();
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: actor.organizationId },
|
||||
select: { name: true },
|
||||
});
|
||||
const message = renderSmsText(body, {
|
||||
name,
|
||||
phone: mobile,
|
||||
title: lead.title,
|
||||
company: lead.contactName ? lead.title : '',
|
||||
company: lead.trade?.trim() || '',
|
||||
from: org?.name?.trim() || '',
|
||||
});
|
||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||
const result = await this.sms.send({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user