Merge branch 'main' of git.targo.ca:Targo/targo_backend into dev/setup/attachment/MatthieuH
This commit is contained in:
commit
95786b9e37
File diff suppressed because it is too large
Load Diff
78
package-lock.json
generated
78
package-lock.json
generated
|
|
@ -54,7 +54,7 @@
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.17.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|
@ -3633,9 +3633,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/config": {
|
"node_modules/@prisma/config": {
|
||||||
"version": "6.14.0",
|
"version": "6.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz",
|
||||||
"integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==",
|
"integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "3.1.0",
|
"c12": "3.1.0",
|
||||||
|
|
@ -3645,48 +3645,48 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/debug": {
|
"node_modules/@prisma/debug": {
|
||||||
"version": "6.14.0",
|
"version": "6.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz",
|
||||||
"integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==",
|
"integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==",
|
||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "6.14.0",
|
"version": "6.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz",
|
||||||
"integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==",
|
"integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.14.0",
|
"@prisma/debug": "6.17.0",
|
||||||
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
|
"@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
|
||||||
"@prisma/fetch-engine": "6.14.0",
|
"@prisma/fetch-engine": "6.17.0",
|
||||||
"@prisma/get-platform": "6.14.0"
|
"@prisma/get-platform": "6.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines-version": {
|
"node_modules/@prisma/engines-version": {
|
||||||
"version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
|
"version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz",
|
||||||
"integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==",
|
"integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==",
|
||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "6.14.0",
|
"version": "6.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz",
|
||||||
"integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==",
|
"integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.14.0",
|
"@prisma/debug": "6.17.0",
|
||||||
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
|
"@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
|
||||||
"@prisma/get-platform": "6.14.0"
|
"@prisma/get-platform": "6.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/get-platform": {
|
"node_modules/@prisma/get-platform": {
|
||||||
"version": "6.14.0",
|
"version": "6.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz",
|
||||||
"integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==",
|
"integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.14.0"
|
"@prisma/debug": "6.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scarf/scarf": {
|
"node_modules/@scarf/scarf": {
|
||||||
|
|
@ -10078,15 +10078,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nypm": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
||||||
"integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==",
|
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"citty": "^0.1.6",
|
"citty": "^0.1.6",
|
||||||
"consola": "^3.4.2",
|
"consola": "^3.4.2",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"pkg-types": "^2.2.0",
|
"pkg-types": "^2.3.0",
|
||||||
"tinyexec": "^1.0.1"
|
"tinyexec": "^1.0.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -10595,9 +10595,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pkg-types": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
"integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==",
|
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"confbox": "^0.2.2",
|
"confbox": "^0.2.2",
|
||||||
|
|
@ -10677,14 +10677,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.14.0",
|
"version": "6.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz",
|
||||||
"integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==",
|
"integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/config": "6.14.0",
|
"@prisma/config": "6.17.0",
|
||||||
"@prisma/engines": "6.14.0"
|
"@prisma/engines": "6.17.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"prisma": "build/index.js"
|
"prisma": "build/index.js"
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.17.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."users" ALTER COLUMN "phone_number" SET DATA TYPE TEXT;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."users" ALTER COLUMN "phone_number" SET DATA TYPE TEXT;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[employee_id,start_date]` on the table `timesheets` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `start_date` to the `timesheets` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."timesheets" ADD COLUMN "start_date" DATE NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "timesheets_employee_id_start_date_key" ON "public"."timesheets"("employee_id", "start_date");
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."shifts" ADD COLUMN "is_remote" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `description` on the `expenses` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `description` on the `expenses_archive` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `description` on the `shifts` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `description` on the `shifts_archive` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."expenses" DROP COLUMN "description",
|
||||||
|
ADD COLUMN "comment" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."expenses_archive" DROP COLUMN "description",
|
||||||
|
ADD COLUMN "comment" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."shifts" DROP COLUMN "description",
|
||||||
|
ADD COLUMN "comment" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."shifts_archive" DROP COLUMN "description",
|
||||||
|
ADD COLUMN "comment" TEXT;
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `attachement` on the `expenses` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `attachement` on the `expenses_archive` table. All the data in the column will be lost.
|
||||||
|
- Made the column `comment` on table `expenses` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."expenses" DROP COLUMN "attachement",
|
||||||
|
ADD COLUMN "attachment" INTEGER,
|
||||||
|
ADD COLUMN "mileage" DECIMAL(65,30),
|
||||||
|
ALTER COLUMN "comment" SET NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."expenses_archive" DROP COLUMN "attachement",
|
||||||
|
ADD COLUMN "attachment" INTEGER,
|
||||||
|
ADD COLUMN "mileage" DECIMAL(65,30),
|
||||||
|
ALTER COLUMN "amount" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."expenses" ADD CONSTRAINT "expenses_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."expenses_archive" ADD CONSTRAINT "expenses_archive_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `end_date_time` on the `leave_requests` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `start_date_time` on the `leave_requests` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `end_date_time` on the `leave_requests_archive` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `start_date_time` on the `leave_requests_archive` table. All the data in the column will be lost.
|
||||||
|
- A unique constraint covering the columns `[employee_id,leave_type,date]` on the table `leave_requests` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[leave_request_id]` on the table `leave_requests_archive` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `date` to the `leave_requests` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `date` to the `leave_requests_archive` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "leave_types" ADD VALUE 'HOLIDAY';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "leave_requests" DROP COLUMN "end_date_time",
|
||||||
|
DROP COLUMN "start_date_time",
|
||||||
|
ADD COLUMN "date" DATE NOT NULL,
|
||||||
|
ADD COLUMN "payable_hours" DECIMAL(5,2),
|
||||||
|
ADD COLUMN "requested_hours" DECIMAL(5,2);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "leave_requests_archive" DROP COLUMN "end_date_time",
|
||||||
|
DROP COLUMN "start_date_time",
|
||||||
|
ADD COLUMN "date" DATE NOT NULL,
|
||||||
|
ADD COLUMN "payable_hours" DECIMAL(5,2),
|
||||||
|
ADD COLUMN "requested_hours" DECIMAL(5,2);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "preferences" (
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"notifications" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"dark_mode" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lang_switch" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lefty_mode" BOOLEAN NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "preferences_user_id_key" ON "preferences"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "leave_requests_employee_id_date_idx" ON "leave_requests"("employee_id", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "leave_requests_employee_id_leave_type_date_key" ON "leave_requests"("employee_id", "leave_type", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "leave_requests_archive_employee_id_date_idx" ON "leave_requests_archive"("employee_id", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "leave_requests_archive_leave_request_id_key" ON "leave_requests_archive"("leave_request_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Weekday" AS ENUM ('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "preferences" ADD COLUMN "id" SERIAL NOT NULL,
|
||||||
|
ADD CONSTRAINT "preferences_pkey" PRIMARY KEY ("id");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "schedule_presets" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"employee_id" INTEGER NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "schedule_presets_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "schedule_preset_shifts" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"preset_id" INTEGER NOT NULL,
|
||||||
|
"bank_code_id" INTEGER NOT NULL,
|
||||||
|
"sort_order" INTEGER NOT NULL,
|
||||||
|
"start_time" TIME(0) NOT NULL,
|
||||||
|
"end_time" TIME(0) NOT NULL,
|
||||||
|
"is_remote" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"week_day" "Weekday" NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "schedule_preset_shifts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "schedule_presets_employee_id_name_key" ON "schedule_presets"("employee_id", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "schedule_preset_shifts_preset_id_week_day_idx" ON "schedule_preset_shifts"("preset_id", "week_day");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "schedule_preset_shifts_preset_id_week_day_sort_order_key" ON "schedule_preset_shifts"("preset_id", "week_day", "sort_order");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "schedule_presets" ADD CONSTRAINT "schedule_presets_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_preset_id_fkey" FOREIGN KEY ("preset_id") REFERENCES "schedule_presets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_bank_code_id_fkey" FOREIGN KEY ("bank_code_id") REFERENCES "bank_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to alter the column `mileage` on the `expenses` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(12,2)`.
|
||||||
|
- You are about to alter the column `mileage` on the `expenses_archive` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(12,2)`.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "expenses" ALTER COLUMN "mileage" SET DATA TYPE DECIMAL(12,2);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "expenses_archive" ALTER COLUMN "mileage" SET DATA TYPE DECIMAL(12,2);
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The `notifications` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||||
|
- The `dark_mode` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||||
|
- The `lang_switch` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||||
|
- The `lefty_mode` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "preferences" DROP COLUMN "notifications",
|
||||||
|
ADD COLUMN "notifications" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
DROP COLUMN "dark_mode",
|
||||||
|
ADD COLUMN "dark_mode" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
DROP COLUMN "lang_switch",
|
||||||
|
ADD COLUMN "lang_switch" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
DROP COLUMN "lefty_mode",
|
||||||
|
ADD COLUMN "lefty_mode" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
@ -5,17 +5,18 @@ const prisma = new PrismaClient();
|
||||||
async function main() {
|
async function main() {
|
||||||
const presets = [
|
const presets = [
|
||||||
// type, categorie, modifier, bank_code
|
// type, categorie, modifier, bank_code
|
||||||
['REGULAR' ,'SHIFT', 1.0 , 'G1'],
|
['REGULAR' ,'SHIFT' , 1.0 , 'G1' ],
|
||||||
['EVENING' ,'SHIFT', 1.25, 'G43'],
|
['OVERTIME' ,'SHIFT' , 2 , 'G43' ],
|
||||||
['Emergency','SHIFT', 2 , 'G48'],
|
['EMERGENCY' ,'SHIFT' , 2 , 'G48' ],
|
||||||
['HOLIDAY' ,'SHIFT', 2.0 , 'G700'],
|
['EVENING' ,'SHIFT' , 1.25 , 'G56' ],
|
||||||
|
['SICK' ,'SHIFT' , 1.0 , 'G105'],
|
||||||
['EXPENSES','EXPENSE', 1.0 , 'G517'],
|
['HOLIDAY' ,'SHIFT' , 1.0 , 'G104'],
|
||||||
['MILEAGE' ,'EXPENSE', 0.72, 'G57'],
|
['VACATION' ,'SHIFT' , 1.0 , 'G305'],
|
||||||
['PER_DIEM','EXPENSE', 1.0 , 'G502'],
|
['ON_CALL' ,'EXPENSE' , 1.0 , 'G202'],
|
||||||
|
['COMMISSION' ,'EXPENSE' , 1.0 , 'G234'],
|
||||||
['SICK' ,'LEAVE', 1.0, 'G105'],
|
['PER_DIEM' ,'EXPENSE' , 1.0 , 'G502'],
|
||||||
['VACATION' ,'LEAVE', 1.0, 'G305'],
|
['MILEAGE' ,'EXPENSE' , 0.72 , 'G503'],
|
||||||
|
['EXPENSES' ,'EXPENSE' , 1.0 , 'G517'],
|
||||||
];
|
];
|
||||||
|
|
||||||
await prisma.bankCodes.createMany({
|
await prisma.bankCodes.createMany({
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,200 @@
|
||||||
import { PrismaClient, Roles } from '@prisma/client';
|
import { PrismaClient, Roles } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
const BASE_PHONE = 1_100_000_000; // < 2_147_483_647
|
|
||||||
|
// base sans underscore, en string
|
||||||
|
const BASE_PHONE = '1100000000';
|
||||||
|
|
||||||
function emailFor(i: number) {
|
function emailFor(i: number) {
|
||||||
return `user${i + 1}@example.test`;
|
return `user${i + 1}@example.test`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// 50 users total: 40 employees + 10 customers
|
type UserSeed = {
|
||||||
// Roles distribution for the 40 employees:
|
|
||||||
// 1 ADMIN, 4 SUPERVISOR, 1 HR, 1 ACCOUNTING, 33 EMPLOYEE
|
|
||||||
// 10 CUSTOMER (non-employees)
|
|
||||||
const usersData: {
|
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone_number: number;
|
phone_number: string;
|
||||||
residence?: string | null;
|
residence?: string | null;
|
||||||
role: Roles;
|
role: Roles;
|
||||||
}[] = [];
|
};
|
||||||
|
|
||||||
const firstNames = ['Alex','Sam','Chris','Jordan','Taylor','Morgan','Jamie','Robin','Avery','Casey'];
|
const usersData: UserSeed[] = [];
|
||||||
const lastNames = ['Smith','Johnson','Williams','Brown','Jones','Miller','Davis','Wilson','Taylor','Clark'];
|
|
||||||
|
|
||||||
// helper to pick
|
const firstNames = ['Alex', 'Sam', 'Chris', 'Jordan', 'Taylor', 'Morgan', 'Jamie', 'Robin', 'Avery', 'Casey'];
|
||||||
const pick = <T>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
|
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Wilson', 'Taylor', 'Clark'];
|
||||||
|
|
||||||
const rolesForEmployees: Roles[] = [
|
const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
Roles.ADMIN,
|
|
||||||
...Array(4).fill(Roles.SUPERVISOR),
|
/**
|
||||||
Roles.HR,
|
* Objectif total: 50 users
|
||||||
Roles.ACCOUNTING,
|
* - 39 employés génériques (dont ADMIN=1, SUPERVISOR=3, HR=1, ACCOUNTING=1, EMPLOYEE=33)
|
||||||
...Array(33).fill(Roles.EMPLOYEE),
|
* - +1 superviseur spécial "User Test" (=> 40 employés)
|
||||||
|
* - 10 customers
|
||||||
|
*/
|
||||||
|
const rolesForEmployees39: Roles[] = [
|
||||||
|
Roles.ADMIN, // 1
|
||||||
|
...Array(3).fill(Roles.SUPERVISOR), // 3 supervisors (le 4e sera "User Test")
|
||||||
|
Roles.HR, // 1
|
||||||
|
Roles.ACCOUNTING, // 1
|
||||||
|
...Array(33).fill(Roles.EMPLOYEE), // 33
|
||||||
|
// total = 39
|
||||||
];
|
];
|
||||||
|
|
||||||
// 40 employees
|
// --- 39 employés génériques: user1..user39@example.test
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 39; i++) {
|
||||||
|
|
||||||
const fn = pick(firstNames);
|
const fn = pick(firstNames);
|
||||||
const ln = pick(lastNames);
|
const ln = pick(lastNames);
|
||||||
usersData.push({
|
usersData.push({
|
||||||
first_name: fn,
|
first_name: fn,
|
||||||
last_name: ln,
|
last_name: ln,
|
||||||
email: emailFor(i),
|
email: emailFor(i),
|
||||||
phone_number: BASE_PHONE + i,
|
phone_number: BASE_PHONE + i.toString(),
|
||||||
residence: Math.random() < 0.5 ? 'QC' : 'ON',
|
residence: Math.random() < 0.5 ? 'QC' : 'ON',
|
||||||
role: rolesForEmployees[i],
|
role: rolesForEmployees39[i],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10 customers
|
// --- 10 customers: user40..user49@example.test
|
||||||
for (let i = 40; i < 50; i++) {
|
for (let i = 39; i < 49; i++) {
|
||||||
const fn = pick(firstNames);
|
const fn = pick(firstNames);
|
||||||
const ln = pick(lastNames);
|
const ln = pick(lastNames);
|
||||||
usersData.push({
|
usersData.push({
|
||||||
first_name: fn,
|
first_name: fn,
|
||||||
last_name: ln,
|
last_name: ln,
|
||||||
email: emailFor(i),
|
email: emailFor(i),
|
||||||
phone_number: BASE_PHONE + i,
|
phone_number: BASE_PHONE + i.toString(),
|
||||||
residence: Math.random() < 0.5 ? 'QC' : 'ON',
|
residence: Math.random() < 0.5 ? 'QC' : 'ON',
|
||||||
role: Roles.CUSTOMER,
|
role: Roles.CUSTOMER,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1) Insert des 49 génériques (skipDuplicates pour rejouer le seed sans erreurs)
|
||||||
await prisma.users.createMany({ data: usersData, skipDuplicates: true });
|
await prisma.users.createMany({ data: usersData, skipDuplicates: true });
|
||||||
console.log('✓ Users: 50 rows (40 employees, 10 customers)');
|
|
||||||
|
// 2) Upsert du superviseur spécial "User Test"
|
||||||
|
const specialEmail = 'user@targointernet.com';
|
||||||
|
const specialUser = await prisma.users.upsert({
|
||||||
|
where: { email: specialEmail },
|
||||||
|
update: {
|
||||||
|
first_name: 'User',
|
||||||
|
last_name: 'Test',
|
||||||
|
role: Roles.SUPERVISOR,
|
||||||
|
residence: 'QC',
|
||||||
|
phone_number: BASE_PHONE + '999',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
first_name: 'User',
|
||||||
|
last_name: 'Test',
|
||||||
|
email: specialEmail,
|
||||||
|
role: Roles.SUPERVISOR,
|
||||||
|
residence: 'QC',
|
||||||
|
phone_number: BASE_PHONE + '999',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Créer/mettre à jour les entrées Employees pour tous les rôles employés
|
||||||
|
const employeeUsers = await prisma.users.findMany({
|
||||||
|
where: { role: { in: [Roles.ADMIN, Roles.SUPERVISOR, Roles.HR, Roles.ACCOUNTING, Roles.EMPLOYEE] } },
|
||||||
|
orderBy: { email: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstWorkDay = new Date('2025-01-06'); // à adapter à ton contexte
|
||||||
|
|
||||||
|
for (let i = 0; i < employeeUsers.length; i++) {
|
||||||
|
const u = employeeUsers[i];
|
||||||
|
await prisma.employees.upsert({
|
||||||
|
where: { user_id: u.id },
|
||||||
|
update: {
|
||||||
|
is_supervisor: u.role === Roles.SUPERVISOR,
|
||||||
|
job_title: u.role,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
user_id: u.id,
|
||||||
|
is_supervisor: u.role === Roles.SUPERVISOR,
|
||||||
|
external_payroll_id: 1000 + i, // à adapter
|
||||||
|
company_code: 1, // à adapter
|
||||||
|
first_work_day: firstWorkDay,
|
||||||
|
job_title: u.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Répartition des 33 EMPLOYEE sur 4 superviseurs: 8/8/8/9 (9 pour User Test)
|
||||||
|
const supervisors = await prisma.employees.findMany({
|
||||||
|
where: { is_supervisor: true, user: { role: Roles.SUPERVISOR } },
|
||||||
|
include: { user: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const userTestSupervisor = supervisors.find((s) => s.user.email === specialEmail);
|
||||||
|
if (!userTestSupervisor) {
|
||||||
|
throw new Error('Employee(User Test) introuvable — vérifie le upsert Users/Employees.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainEmployees = await prisma.employees.findMany({
|
||||||
|
where: { is_supervisor: false, user: { role: Roles.EMPLOYEE } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si la configuration est bien 4 superviseurs + 33 employés, on force 8/8/8/9 avec 9 pour User Test.
|
||||||
|
if (supervisors.length === 4 && plainEmployees.length === 33) {
|
||||||
|
const others = supervisors.filter((s) => s.id !== userTestSupervisor.id);
|
||||||
|
// ordre: autres (3) puis User Test en dernier (reçoit 9)
|
||||||
|
const ordered = [...others, userTestSupervisor];
|
||||||
|
|
||||||
|
const chunks = [
|
||||||
|
plainEmployees.slice(0, 8), // -> sup 0
|
||||||
|
plainEmployees.slice(8, 16), // -> sup 1
|
||||||
|
plainEmployees.slice(16, 24), // -> sup 2
|
||||||
|
plainEmployees.slice(24, 33), // -> sup 3 (User Test) = 9
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let b = 0; b < chunks.length; b++) {
|
||||||
|
const sup = ordered[b];
|
||||||
|
for (const emp of chunks[b]) {
|
||||||
|
await prisma.employees.update({
|
||||||
|
where: { id: emp.id },
|
||||||
|
data: { supervisor_id: sup.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// fallback: distribution round-robin si la config diffère
|
||||||
|
console.warn(
|
||||||
|
`Répartition fallback (round-robin). Supervisors=${supervisors.length}, Employees=${plainEmployees.length}`
|
||||||
|
);
|
||||||
|
const others = supervisors.filter((s) => s.id !== userTestSupervisor.id);
|
||||||
|
const ordered = [...others, userTestSupervisor];
|
||||||
|
for (let i = 0; i < plainEmployees.length; i++) {
|
||||||
|
const sup = ordered[i % ordered.length];
|
||||||
|
await prisma.employees.update({
|
||||||
|
where: { id: plainEmployees[i].id },
|
||||||
|
data: { supervisor_id: sup.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Sanity checks
|
||||||
|
const totalUsers = await prisma.users.count();
|
||||||
|
const supCount = await prisma.users.count({ where: { role: Roles.SUPERVISOR } });
|
||||||
|
const empCount = await prisma.users.count({ where: { role: Roles.EMPLOYEE } });
|
||||||
|
|
||||||
|
const countForUserTest = await prisma.employees.count({
|
||||||
|
where: { supervisor_id: userTestSupervisor.id, is_supervisor: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Users total: ${totalUsers} (attendu 50)`);
|
||||||
|
console.log(`✓ Supervisors: ${supCount} (attendu 4)`);
|
||||||
|
console.log(`✓ Employees : ${empCount} (attendu 33)`);
|
||||||
|
console.log(`✓ Employés sous User Test: ${countForUserTest} (attendu 9)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ function randomPastDate(yearsBack = 3) {
|
||||||
past.setFullYear(now.getFullYear() - yearsBack);
|
past.setFullYear(now.getFullYear() - yearsBack);
|
||||||
const t = randInt(past.getTime(), now.getTime());
|
const t = randInt(past.getTime(), now.getTime());
|
||||||
const d = new Date(t);
|
const d = new Date(t);
|
||||||
d.setHours(0,0,0,0);
|
d.setHours(0, 0, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +29,12 @@ const jobTitles = [
|
||||||
];
|
];
|
||||||
|
|
||||||
function randomTitle() {
|
function randomTitle() {
|
||||||
return jobTitles[randInt(0, jobTitles.length -1)];
|
return jobTitles[randInt(0, jobTitles.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sélection aléatoire entre 271583 et 271585
|
||||||
|
function randomCompanyCode() {
|
||||||
|
return Math.random() < 0.5 ? 271583 : 271585;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|
@ -38,40 +43,41 @@ async function main() {
|
||||||
orderBy: { email: 'asc' },
|
orderBy: { email: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create supervisors first
|
// 1) Trouver le user qui sera le superviseur fixe
|
||||||
const supervisorUsers = employeeUsers.filter(u => u.role === Roles.SUPERVISOR);
|
const supervisorUser = await prisma.users.findUnique({
|
||||||
const supervisorEmployeeIds: number[] = [];
|
where: { email: 'user5@example.test' },
|
||||||
|
});
|
||||||
|
if (!supervisorUser) {
|
||||||
|
throw new Error("Le user 'user5@example.test' n'existe pas !");
|
||||||
|
}
|
||||||
|
|
||||||
for (const u of supervisorUsers) {
|
// 2) Créer ou récupérer son employee avec is_supervisor = true
|
||||||
const emp = await prisma.employees.upsert({
|
const supervisorEmp = await prisma.employees.upsert({
|
||||||
where: { user_id: u.id },
|
where: { user_id: supervisorUser.id },
|
||||||
update: {},
|
update: { is_supervisor: true },
|
||||||
create: {
|
create: {
|
||||||
user_id: u.id,
|
user_id: supervisorUser.id,
|
||||||
external_payroll_id: randInt(10000, 99999),
|
external_payroll_id: randInt(10000, 99999),
|
||||||
company_code: randInt(1, 5),
|
company_code: randomCompanyCode(),
|
||||||
first_work_day: randomPastDate(3),
|
first_work_day: randomPastDate(3),
|
||||||
last_work_day: null,
|
last_work_day: null,
|
||||||
job_title: randomTitle(),
|
job_title: randomTitle(),
|
||||||
is_supervisor: true,
|
is_supervisor: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
supervisorEmployeeIds.push(emp.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create remaining employees, assign a random supervisor (admin can have none)
|
// 3) Créer tous les autres employés avec ce superviseur (sauf ADMIN qui n’a pas de superviseur)
|
||||||
for (const u of employeeUsers) {
|
for (const u of employeeUsers) {
|
||||||
const already = await prisma.employees.findUnique({ where: { user_id: u.id } });
|
const already = await prisma.employees.findUnique({ where: { user_id: u.id } });
|
||||||
if (already) continue;
|
if (already) continue;
|
||||||
|
|
||||||
const supervisor_id =
|
const supervisor_id = u.role === Roles.ADMIN ? null : supervisorEmp.id;
|
||||||
u.role === Roles.ADMIN ? null : supervisorEmployeeIds[randInt(0, supervisorEmployeeIds.length - 1)];
|
|
||||||
|
|
||||||
await prisma.employees.create({
|
await prisma.employees.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: u.id,
|
user_id: u.id,
|
||||||
external_payroll_id: randInt(10000, 99999),
|
external_payroll_id: randInt(10000, 99999),
|
||||||
company_code: randInt(1, 5),
|
company_code: randomCompanyCode(),
|
||||||
first_work_day: randomPastDate(3),
|
first_work_day: randomPastDate(3),
|
||||||
last_work_day: null,
|
last_work_day: null,
|
||||||
supervisor_id,
|
supervisor_id,
|
||||||
|
|
@ -81,7 +87,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = await prisma.employees.count();
|
const total = await prisma.employees.count();
|
||||||
console.log(`✓ Employees: ${total} rows (with supervisors linked)`);
|
console.log(`✓ Employees: ${total} rows (supervisor = ${supervisorUser.email})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
main().finally(() => prisma.$disconnect());
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
|
||||||
|
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function daysAgo(n: number) {
|
function daysAgo(n: number) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
// prisma/mock-seeds-scripts/06-customers-archive.ts
|
// prisma/mock-seeds-scripts/06-customers-archive.ts
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
|
||||||
|
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { PrismaClient, Prisma, LeaveTypes, LeaveApprovalStatus } from '@prisma/client';
|
import { PrismaClient, Prisma, LeaveTypes, LeaveApprovalStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
|
||||||
|
console.log('?? Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function dateOn(y: number, m: number, d: number) {
|
function dateOn(y: number, m: number, d: number) {
|
||||||
// stocke une date (pour @db.Date) à minuit UTC
|
// stocke une date (@db.Date) à minuit UTC
|
||||||
return new Date(Date.UTC(y, m - 1, d, 0, 0, 0));
|
return new Date(Date.UTC(y, m - 1, d, 0, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -14,7 +19,7 @@ async function main() {
|
||||||
const employees = await prisma.employees.findMany({ select: { id: true } });
|
const employees = await prisma.employees.findMany({ select: { id: true } });
|
||||||
const bankCodes = await prisma.bankCodes.findMany({
|
const bankCodes = await prisma.bankCodes.findMany({
|
||||||
where: { categorie: 'LEAVE' },
|
where: { categorie: 'LEAVE' },
|
||||||
select: { id: true },
|
select: { id: true, type: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!employees.length || !bankCodes.length) {
|
if (!employees.length || !bankCodes.length) {
|
||||||
|
|
@ -39,30 +44,31 @@ async function main() {
|
||||||
LeaveApprovalStatus.ESCALATED,
|
LeaveApprovalStatus.ESCALATED,
|
||||||
];
|
];
|
||||||
|
|
||||||
const futureMonths = [8, 9, 10, 11, 12]; // Août→Déc (1-based)
|
const futureMonths = [8, 9, 10, 11, 12]; // Août ? Déc. (1-based)
|
||||||
|
|
||||||
// ✅ typer rows pour éviter never[]
|
|
||||||
const rows: Prisma.LeaveRequestsCreateManyInput[] = [];
|
const rows: Prisma.LeaveRequestsCreateManyInput[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const emp = employees[i % employees.length];
|
const emp = employees[i % employees.length];
|
||||||
const m = futureMonths[i % futureMonths.length];
|
const m = futureMonths[i % futureMonths.length];
|
||||||
const start = dateOn(year, m, 5 + i); // 5..14
|
const date = dateOn(year, m, 5 + i); // 5..14
|
||||||
if (start <= today) continue; // garantir "futur"
|
if (date <= today) continue; // garantir « futur »
|
||||||
|
|
||||||
const end = Math.random() < 0.5 ? null : dateOn(year, m, 6 + i);
|
|
||||||
const type = types[i % types.length];
|
const type = types[i % types.length];
|
||||||
const status = statuses[i % statuses.length];
|
const status = statuses[i % statuses.length];
|
||||||
const bc = bankCodes[i % bankCodes.length];
|
const bc = bankCodes[i % bankCodes.length];
|
||||||
|
const requestedHours = 4 + (i % 5); // 4 ? 8 h
|
||||||
|
const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null;
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
employee_id: emp.id,
|
employee_id: emp.id,
|
||||||
bank_code_id: bc.id,
|
bank_code_id: bc.id,
|
||||||
leave_type: type,
|
leave_type: type,
|
||||||
start_date_time: start,
|
date,
|
||||||
end_date_time: end, // ok: Date | null
|
comment: `Future leave #${i + 1} (${bc.type})`,
|
||||||
comment: `Future leave #${i + 1}`,
|
|
||||||
approval_status: status,
|
approval_status: status,
|
||||||
|
requested_hours: requestedHours,
|
||||||
|
payable_hours: payableHours,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +76,7 @@ async function main() {
|
||||||
await prisma.leaveRequests.createMany({ data: rows });
|
await prisma.leaveRequests.createMany({ data: rows });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✓ LeaveRequests (future): ${rows.length} rows`);
|
console.log(`? LeaveRequests (future): ${rows.length} rows`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
main().finally(() => prisma.$disconnect());
|
||||||
|
|
@ -1,42 +1,69 @@
|
||||||
import { PrismaClient, LeaveTypes, LeaveApprovalStatus, LeaveRequests } from '@prisma/client';
|
import { PrismaClient, LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
|
||||||
|
|
||||||
|
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
|
||||||
|
console.log('?? Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function daysAgo(n:number) {
|
function daysAgo(n: number) {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setUTCDate(d.getUTCDate() - n);
|
d.setUTCDate(d.getUTCDate() - n);
|
||||||
d.setUTCHours(0,0,0,0);
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const employees = await prisma.employees.findMany({ select: { id: true } });
|
const employees = await prisma.employees.findMany({ select: { id: true } });
|
||||||
const bankCodes = await prisma.bankCodes.findMany({ select: { id: true }, where: { categorie: 'LEAVE' } });
|
if (!employees.length) {
|
||||||
|
throw new Error('Aucun employé trouvé. Exécute le seed employees avant celui-ci.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveCodes = await prisma.bankCodes.findMany({
|
||||||
|
where: { type: { in: ['SICK', 'VACATION', 'HOLIDAY'] } },
|
||||||
|
select: { id: true, type: true },
|
||||||
|
});
|
||||||
|
if (!leaveCodes.length) {
|
||||||
|
throw new Error("Aucun bank code trouvé avec type in ('SICK','VACATION','HOLIDAY'). Vérifie ta table bank_codes.");
|
||||||
|
}
|
||||||
|
|
||||||
const types = Object.values(LeaveTypes);
|
|
||||||
const statuses = Object.values(LeaveApprovalStatus);
|
const statuses = Object.values(LeaveApprovalStatus);
|
||||||
|
const created = [] as Array<{ id: number; employee_id: number; leave_type: LeaveTypes; date: Date; comment: string; approval_status: LeaveApprovalStatus; requested_hours: number; payable_hours: number | null }>;
|
||||||
|
|
||||||
const created: LeaveRequests[] = [];
|
const COUNT = 12;
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const emp = employees[i % employees.length];
|
const emp = employees[i % employees.length];
|
||||||
const bc = bankCodes[i % bankCodes.length];
|
const leaveCode = leaveCodes[Math.floor(Math.random() * leaveCodes.length)];
|
||||||
const start = daysAgo(120 - i * 3); // tous avant aujourd'hui
|
|
||||||
const end = Math.random() < 0.4 ? null : daysAgo(119 - i * 3);
|
const date = daysAgo(120 - i * 3);
|
||||||
|
const status = statuses[(i + 2) % statuses.length];
|
||||||
|
const requestedHours = 4 + (i % 5); // 4 ? 8 h
|
||||||
|
const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null;
|
||||||
|
|
||||||
const lr = await prisma.leaveRequests.create({
|
const lr = await prisma.leaveRequests.create({
|
||||||
data: {
|
data: {
|
||||||
employee_id: emp.id,
|
employee_id: emp.id,
|
||||||
bank_code_id: bc.id,
|
bank_code_id: leaveCode.id,
|
||||||
leave_type: types[i % types.length],
|
leave_type: leaveCode.type as LeaveTypes,
|
||||||
start_date_time: start,
|
date,
|
||||||
end_date_time: end,
|
comment: `Past leave #${i + 1} (${leaveCode.type})`,
|
||||||
comment: `Past leave #${i+1}`,
|
approval_status: status,
|
||||||
approval_status: statuses[(i+2) % statuses.length],
|
requested_hours: requestedHours,
|
||||||
|
payable_hours: payableHours,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
created.push(lr);
|
created.push({
|
||||||
|
id: lr.id,
|
||||||
|
employee_id: lr.employee_id,
|
||||||
|
leave_type: lr.leave_type,
|
||||||
|
date: lr.date,
|
||||||
|
comment: lr.comment,
|
||||||
|
approval_status: lr.approval_status,
|
||||||
|
requested_hours: requestedHours,
|
||||||
|
payable_hours: payableHours,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const lr of created) {
|
for (const lr of created) {
|
||||||
|
|
@ -45,15 +72,16 @@ async function main() {
|
||||||
leave_request_id: lr.id,
|
leave_request_id: lr.id,
|
||||||
employee_id: lr.employee_id,
|
employee_id: lr.employee_id,
|
||||||
leave_type: lr.leave_type,
|
leave_type: lr.leave_type,
|
||||||
start_date_time: lr.start_date_time,
|
date: lr.date,
|
||||||
end_date_time: lr.end_date_time,
|
|
||||||
comment: lr.comment,
|
comment: lr.comment,
|
||||||
approval_status: lr.approval_status,
|
approval_status: lr.approval_status,
|
||||||
|
requested_hours: lr.requested_hours,
|
||||||
|
payable_hours: lr.payable_hours,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✓ LeaveRequestsArchive: ${created.length} rows`);
|
console.log(`? LeaveRequestsArchive: ${created.length} rows`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
main().finally(() => prisma.$disconnect());
|
||||||
|
|
@ -2,26 +2,59 @@ import { PrismaClient, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// ====== Config ======
|
||||||
|
const PREVIOUS_WEEKS = 16; // nombre de semaines à créer (passé)
|
||||||
|
const INCLUDE_CURRENT = false; // true si tu veux aussi la semaine courante
|
||||||
|
|
||||||
|
// Lundi (UTC) de la semaine courante
|
||||||
|
function mondayOfThisWeekUTC(now = new Date()) {
|
||||||
|
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||||
|
const day = d.getUTCDay(); // 0=Dim, 1=Lun, ...
|
||||||
|
const diffToMonday = (day + 6) % 7; // 0 si lundi
|
||||||
|
d.setUTCDate(d.getUTCDate() - diffToMonday);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function mondayNWeeksBefore(monday: Date, n: number) {
|
||||||
|
const d = new Date(monday);
|
||||||
|
d.setUTCDate(d.getUTCDate() - n * 7);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const employees = await prisma.employees.findMany({ select: { id: true } });
|
const employees = await prisma.employees.findMany({ select: { id: true } });
|
||||||
|
if (!employees.length) {
|
||||||
|
console.warn('Aucun employé — rien à insérer.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ typer rows pour éviter never[]
|
// Construit la liste des lundis (1 par semaine)
|
||||||
|
const mondays: Date[] = [];
|
||||||
|
const mondayThisWeek = mondayOfThisWeekUTC();
|
||||||
|
if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
|
||||||
|
for (let n = 1; n <= PREVIOUS_WEEKS; n++) {
|
||||||
|
mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prépare les lignes (1 timesheet / employé / semaine)
|
||||||
const rows: Prisma.TimesheetsCreateManyInput[] = [];
|
const rows: Prisma.TimesheetsCreateManyInput[] = [];
|
||||||
|
|
||||||
// 8 timesheets / employee
|
|
||||||
for (const e of employees) {
|
for (const e of employees) {
|
||||||
for (let i = 0; i < 8; i++) {
|
for (const monday of mondays) {
|
||||||
const is_approved = Math.random() < 0.3;
|
rows.push({
|
||||||
rows.push({ employee_id: e.id, is_approved });
|
employee_id: e.id,
|
||||||
|
start_date: monday,
|
||||||
|
is_approved: Math.random() < 0.3,
|
||||||
|
} as Prisma.TimesheetsCreateManyInput);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert en bulk et ignore les doublons si déjà présents
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
await prisma.timesheets.createMany({ data: rows });
|
await prisma.timesheets.createMany({ data: rows, skipDuplicates: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = await prisma.timesheets.count();
|
const total = await prisma.timesheets.count();
|
||||||
console.log(`✓ Timesheets: ${total} rows (added ${rows.length})`);
|
console.log(`✓ Timesheets: ${total} rows (ajout potentiel: ${rows.length}, ${INCLUDE_CURRENT ? 'courante +' : ''}${PREVIOUS_WEEKS} semaines)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
main().finally(() => prisma.$disconnect());
|
||||||
|
|
|
||||||
|
|
@ -2,54 +2,233 @@ import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function timeAt(hour:number, minute:number) {
|
// ====== Config ======
|
||||||
// stocker une heure (Postgres TIME) via Date (UTC 1970-01-01)
|
const PREVIOUS_WEEKS = 5;
|
||||||
|
const INCLUDE_CURRENT = true;
|
||||||
|
const INCR = 15; // incrément ferme de 15 minutes (0.25 h)
|
||||||
|
const DAY_MIN = 5 * 60; // 5h
|
||||||
|
const DAY_MAX = 11 * 60; // 11h
|
||||||
|
const HARD_END = 19 * 60 + 30; // 19:30
|
||||||
|
|
||||||
|
// ====== Helpers temps ======
|
||||||
|
function timeAt(hour: number, minute: number) {
|
||||||
return new Date(Date.UTC(1970, 0, 1, hour, minute, 0));
|
return new Date(Date.UTC(1970, 0, 1, hour, minute, 0));
|
||||||
}
|
}
|
||||||
function daysAgo(n:number) {
|
function mondayOfThisWeekUTC(now = new Date()) {
|
||||||
const d = new Date();
|
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||||
d.setUTCDate(d.getUTCDate() - n);
|
const day = d.getUTCDay();
|
||||||
d.setUTCHours(0,0,0,0);
|
const diffToMonday = (day + 6) % 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() - diffToMonday);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
function weekDatesFromMonday(monday: Date) {
|
||||||
|
return Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const d = new Date(monday);
|
||||||
|
d.setUTCDate(monday.getUTCDate() + i);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function mondayNWeeksBefore(monday: Date, n: number) {
|
||||||
|
const d = new Date(monday);
|
||||||
|
d.setUTCDate(d.getUTCDate() - n * 7);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function rndInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
function clamp(n: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
function addMinutes(h: number, m: number, delta: number) {
|
||||||
|
const total = h * 60 + m + delta;
|
||||||
|
const hh = Math.floor(total / 60);
|
||||||
|
const mm = ((total % 60) + 60) % 60;
|
||||||
|
return { h: hh, m: mm };
|
||||||
|
}
|
||||||
|
// Aligne vers le multiple de INCR le plus proche
|
||||||
|
function quantize(mins: number): number {
|
||||||
|
const q = Math.round(mins / INCR) * INCR;
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
// Tire un multiple de INCR dans [min,max] (inclus), supposés entiers minutes
|
||||||
|
function rndQuantized(min: number, max: number): number {
|
||||||
|
const qmin = Math.ceil(min / INCR);
|
||||||
|
const qmax = Math.floor(max / INCR);
|
||||||
|
const q = rndInt(qmin, qmax);
|
||||||
|
return q * INCR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: garantit le timesheet de la semaine (upsert)
|
||||||
|
async function getOrCreateTimesheet(employee_id: number, start_date: Date) {
|
||||||
|
return prisma.timesheets.upsert({
|
||||||
|
where: { employee_id_start_date: { employee_id, start_date } },
|
||||||
|
update: {},
|
||||||
|
create: { employee_id, start_date, is_approved: Math.random() < 0.3 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const bankCodes = await prisma.bankCodes.findMany({ where: { categorie: 'SHIFT' }, select: { id: true } });
|
// --- Bank codes (pondérés: surtout G1 = régulier) ---
|
||||||
if (!bankCodes.length) throw new Error('Need SHIFT bank codes');
|
const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305'] as const;
|
||||||
|
const WEIGHTED_CODES = [
|
||||||
|
'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1',
|
||||||
|
'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const bcRows = await prisma.bankCodes.findMany({
|
||||||
|
where: { bank_code: { in: BANKS as unknown as string[] } },
|
||||||
|
select: { id: true, bank_code: true },
|
||||||
|
});
|
||||||
|
const bcMap = new Map(bcRows.map(b => [b.bank_code, b.id]));
|
||||||
|
for (const c of BANKS) {
|
||||||
|
if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`);
|
||||||
|
}
|
||||||
|
|
||||||
const employees = await prisma.employees.findMany({ select: { id: true } });
|
const employees = await prisma.employees.findMany({ select: { id: true } });
|
||||||
|
if (!employees.length) {
|
||||||
|
console.log('Aucun employé — rien à insérer.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const e of employees) {
|
const mondayThisWeek = mondayOfThisWeekUTC();
|
||||||
const tss = await prisma.timesheets.findMany({
|
const mondays: Date[] = [];
|
||||||
where: { employee_id: e.id },
|
if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
|
||||||
select: { id: true },
|
for (let n = 1; n <= PREVIOUS_WEEKS; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
|
||||||
});
|
|
||||||
if (!tss.length) continue;
|
|
||||||
|
|
||||||
// 10 shifts / employee
|
let created = 0;
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const ts = tss[i % tss.length];
|
|
||||||
const bc = bankCodes[i % bankCodes.length];
|
|
||||||
const date = daysAgo(7 + i); // la dernière quinzaine
|
|
||||||
const startH = 8 + (i % 3); // 8..10
|
|
||||||
const endH = startH + 7 + (i % 2); // 15..17
|
|
||||||
|
|
||||||
|
for (let wi = 0; wi < mondays.length; wi++) {
|
||||||
|
const monday = mondays[wi];
|
||||||
|
const days = weekDatesFromMonday(monday);
|
||||||
|
|
||||||
|
for (let ei = 0; ei < employees.length; ei++) {
|
||||||
|
const e = employees[ei];
|
||||||
|
|
||||||
|
// Cible hebdo 35–45h, multiple de 15 min
|
||||||
|
const weeklyTargetMin = rndQuantized(35 * 60, 45 * 60);
|
||||||
|
|
||||||
|
// Start de base (7:00, 7:15, 7:30, 7:45, 8:00, 8:15, 8:30, 8:45, 9:00 ...)
|
||||||
|
const baseStartH = 7 + (ei % 3); // 7,8,9
|
||||||
|
const baseStartM = ( (ei * 15) % 60 ); // aligné 15 min
|
||||||
|
|
||||||
|
// Planification journalière (5 jours) ~8h ± 45 min, quantisée 15 min
|
||||||
|
const plannedDaily: number[] = [];
|
||||||
|
for (let d = 0; d < 5; d++) {
|
||||||
|
const jitter = rndInt(-3, 3) * INCR; // -45..+45 par pas de 15
|
||||||
|
const base = 8 * 60 + jitter;
|
||||||
|
plannedDaily.push(quantize(clamp(base, DAY_MIN, DAY_MAX)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajuster le 5e jour pour atteindre la cible hebdo exactement (par pas de 15)
|
||||||
|
const sumFirst4 = plannedDaily.slice(0, 4).reduce((a, b) => a + b, 0);
|
||||||
|
plannedDaily[4] = quantize(clamp(weeklyTargetMin - sumFirst4, DAY_MIN, DAY_MAX));
|
||||||
|
|
||||||
|
// Corriger le petit écart restant (devrait être multiple de 15) en redistribuant ±15
|
||||||
|
let diff = weeklyTargetMin - plannedDaily.reduce((a, b) => a + b, 0);
|
||||||
|
const step = diff > 0 ? INCR : -INCR;
|
||||||
|
let guard = 100; // anti-boucle
|
||||||
|
while (diff !== 0 && guard-- > 0) {
|
||||||
|
for (let d = 0; d < 5 && diff !== 0; d++) {
|
||||||
|
const next = plannedDaily[d] + step;
|
||||||
|
if (next >= DAY_MIN && next <= DAY_MAX) {
|
||||||
|
plannedDaily[d] = next;
|
||||||
|
diff -= step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert du timesheet (semaine)
|
||||||
|
const ts = await getOrCreateTimesheet(e.id, mondayOfThisWeekUTC(days[0]));
|
||||||
|
|
||||||
|
for (let di = 0; di < 5; di++) {
|
||||||
|
const date = days[di];
|
||||||
|
const targetWorkMin = plannedDaily[di]; // multiple de 15
|
||||||
|
|
||||||
|
// Départ ~ base + jitter (par pas de 15 min aussi)
|
||||||
|
const startJitter = rndInt(-1, 2) * INCR; // -15,0,+15,+30
|
||||||
|
const { h: startH, m: startM } = addMinutes(baseStartH, baseStartM, startJitter);
|
||||||
|
|
||||||
|
// Pause: entre 11:00 et 14:00, mais pas avant start+3h ni après start+6h (le tout quantisé 15)
|
||||||
|
const earliestLunch = Math.max((startH * 60 + startM) + 3 * 60, 11 * 60);
|
||||||
|
const latestLunch = Math.min((startH * 60 + startM) + 6 * 60, 14 * 60);
|
||||||
|
const lunchStartMin = rndQuantized(earliestLunch, latestLunch);
|
||||||
|
const lunchDur = rndQuantized(30, 120); // 30..120 min en pas de 15
|
||||||
|
const lunchEndMin = lunchStartMin + lunchDur;
|
||||||
|
|
||||||
|
// Travail = (lunchStart - start) + (end - lunchEnd)
|
||||||
|
const morningWork = Math.max(0, lunchStartMin - (startH * 60 + startM)); // multiple de 15
|
||||||
|
let afternoonWork = Math.max(60, targetWorkMin - morningWork); // multiple de 15 (diff de deux multiples de 15)
|
||||||
|
if (afternoonWork % INCR !== 0) {
|
||||||
|
// sécurité (ne devrait pas arriver)
|
||||||
|
afternoonWork = quantize(afternoonWork);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fin de journée (quantisée par construction)
|
||||||
|
const endMinRaw = lunchEndMin + afternoonWork;
|
||||||
|
const endMin = Math.min(endMinRaw, HARD_END);
|
||||||
|
|
||||||
|
// Bank codes variés
|
||||||
|
const bcMorningCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)];
|
||||||
|
const bcAfternoonCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)];
|
||||||
|
const bcMorningId = bcMap.get(bcMorningCode)!;
|
||||||
|
const bcAfternoonId = bcMap.get(bcAfternoonCode)!;
|
||||||
|
|
||||||
|
// Shift matin
|
||||||
|
const lunchStartHM = { h: Math.floor(lunchStartMin / 60), m: lunchStartMin % 60 };
|
||||||
await prisma.shifts.create({
|
await prisma.shifts.create({
|
||||||
data: {
|
data: {
|
||||||
timesheet_id: ts.id,
|
timesheet_id: ts.id,
|
||||||
bank_code_id: bc.id,
|
bank_code_id: bcMorningId,
|
||||||
description: `Shift ${i + 1} for emp ${e.id}`,
|
comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcMorningCode}`,
|
||||||
date,
|
date,
|
||||||
start_time: timeAt(startH, 0),
|
start_time: timeAt(startH, startM),
|
||||||
end_time: timeAt(endH, 0),
|
end_time: timeAt(lunchStartHM.h, lunchStartHM.m),
|
||||||
is_approved: Math.random() < 0.5,
|
is_approved: Math.random() < 0.6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
created++;
|
||||||
|
|
||||||
|
// Shift après-midi (si >= 30 min — sera de toute façon multiple de 15)
|
||||||
|
const pmDuration = endMin - lunchEndMin;
|
||||||
|
if (pmDuration >= 30) {
|
||||||
|
const lunchEndHM = { h: Math.floor(lunchEndMin / 60), m: lunchEndMin % 60 };
|
||||||
|
const finalEndHM = { h: Math.floor(endMin / 60), m: endMin % 60 };
|
||||||
|
await prisma.shifts.create({
|
||||||
|
data: {
|
||||||
|
timesheet_id: ts.id,
|
||||||
|
bank_code_id: bcAfternoonId,
|
||||||
|
comment: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcAfternoonCode}`,
|
||||||
|
date,
|
||||||
|
start_time: timeAt(lunchEndHM.h, lunchEndHM.m),
|
||||||
|
end_time: timeAt(finalEndHM.h, finalEndHM.m),
|
||||||
|
is_approved: Math.random() < 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
} else {
|
||||||
|
// Fallback très rare : un seul shift couvrant la journée (tout en multiples de 15)
|
||||||
|
const fallbackEnd = addMinutes(startH, startM, targetWorkMin + lunchDur);
|
||||||
|
await prisma.shifts.create({
|
||||||
|
data: {
|
||||||
|
timesheet_id: ts.id,
|
||||||
|
bank_code_id: bcMap.get('G1')!,
|
||||||
|
comment: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`,
|
||||||
|
date,
|
||||||
|
start_time: timeAt(startH, startM),
|
||||||
|
end_time: timeAt(fallbackEnd.h, fallbackEnd.m),
|
||||||
|
is_approved: Math.random() < 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = await prisma.shifts.count();
|
const total = await prisma.shifts.count();
|
||||||
console.log(`✓ Shifts: ${total} total rows`);
|
console.log(`✓ Shifts créés: ${created} | total en DB: ${total} (${INCLUDE_CURRENT ? 'inclut semaine courante, ' : ''}${PREVIOUS_WEEKS} sem passées, L→V, 2 shifts/jour, pas de décimaux foireux})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
main().finally(() => prisma.$disconnect());
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
|
||||||
|
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function timeAt(h:number,m:number) {
|
function timeAt(h:number,m:number) {
|
||||||
|
|
@ -21,7 +26,7 @@ async function main() {
|
||||||
if (!tss.length) continue;
|
if (!tss.length) continue;
|
||||||
|
|
||||||
const createdShiftIds: number[] = [];
|
const createdShiftIds: number[] = [];
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
const ts = tss[i % tss.length];
|
const ts = tss[i % tss.length];
|
||||||
const bc = bankCodes[i % bankCodes.length];
|
const bc = bankCodes[i % bankCodes.length];
|
||||||
const date = daysAgo(200 + i); // bien dans le passé
|
const date = daysAgo(200 + i); // bien dans le passé
|
||||||
|
|
@ -32,7 +37,7 @@ async function main() {
|
||||||
data: {
|
data: {
|
||||||
timesheet_id: ts.id,
|
timesheet_id: ts.id,
|
||||||
bank_code_id: bc.id,
|
bank_code_id: bc.id,
|
||||||
description: `Archived-era shift ${i + 1} for emp ${e.id}`,
|
comment: `Archived-era shift ${i + 1} for emp ${e.id}`,
|
||||||
date,
|
date,
|
||||||
start_time: timeAt(startH, 0),
|
start_time: timeAt(startH, 0),
|
||||||
end_time: timeAt(endH, 0),
|
end_time: timeAt(endH, 0),
|
||||||
|
|
@ -50,7 +55,7 @@ async function main() {
|
||||||
shift_id: s.id,
|
shift_id: s.id,
|
||||||
timesheet_id: s.timesheet_id,
|
timesheet_id: s.timesheet_id,
|
||||||
bank_code_id: s.bank_code_id,
|
bank_code_id: s.bank_code_id,
|
||||||
description: s.description,
|
comment: s.comment,
|
||||||
date: s.date,
|
date: s.date,
|
||||||
start_time: s.start_time,
|
start_time: s.start_time,
|
||||||
end_time: s.end_time,
|
end_time: s.end_time,
|
||||||
|
|
|
||||||
|
|
@ -2,43 +2,165 @@ import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function daysAgo(n:number) {
|
// ====== Config ======
|
||||||
const d = new Date();
|
const WEEKS_BACK = 4; // 4 semaines avant + semaine courante
|
||||||
d.setUTCDate(d.getUTCDate() - n);
|
const INCLUDE_CURRENT = true; // inclure la semaine courante
|
||||||
d.setUTCHours(0,0,0,0);
|
const STEP_CENTS = 25; // montants en quarts de dollar (.00/.25/.50/.75)
|
||||||
|
|
||||||
|
// ====== Helpers dates ======
|
||||||
|
function mondayOfThisWeekUTC(now = new Date()) {
|
||||||
|
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||||
|
const day = d.getUTCDay();
|
||||||
|
const diffToMonday = (day + 6) % 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() - diffToMonday);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
function mondayNWeeksBefore(monday: Date, n: number) {
|
||||||
|
const d = new Date(monday);
|
||||||
|
d.setUTCDate(monday.getUTCDate() - n * 7);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
// L→V (UTC minuit)
|
||||||
|
function weekDatesMonToFri(monday: Date) {
|
||||||
|
return Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const d = new Date(monday);
|
||||||
|
d.setUTCDate(monday.getUTCDate() + i);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Helpers random / amount ======
|
||||||
|
function rndInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
// String "xx.yy" à partir de cents ENTiers (jamais de float)
|
||||||
|
function centsToAmountString(cents: number): string {
|
||||||
|
const sign = cents < 0 ? '-' : '';
|
||||||
|
const abs = Math.abs(cents);
|
||||||
|
const dollars = Math.floor(abs / 100);
|
||||||
|
const c = abs % 100;
|
||||||
|
return `${sign}${dollars}.${c.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function to2(value: string): string {
|
||||||
|
// normalise au cas où (sécurité)
|
||||||
|
return (Math.round(parseFloat(value) * 100) / 100).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus)
|
||||||
|
function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number {
|
||||||
|
const qmin = Math.ceil(minCents / step);
|
||||||
|
const qmax = Math.floor(maxCents / step);
|
||||||
|
const q = rndInt(qmin, qmax);
|
||||||
|
return q * step;
|
||||||
|
}
|
||||||
|
function rndAmount(minCents: number, maxCents: number): string {
|
||||||
|
return centsToAmountString(rndQuantizedCents(minCents, maxCents));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Timesheet upsert ======
|
||||||
|
async function getOrCreateTimesheet(employee_id: number, start_date: Date) {
|
||||||
|
return prisma.timesheets.upsert({
|
||||||
|
where: { employee_id_start_date: { employee_id, start_date } },
|
||||||
|
update: {},
|
||||||
|
create: { employee_id, start_date, is_approved: Math.random() < 0.3 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const expenseCodes = await prisma.bankCodes.findMany({ where: { categorie: 'EXPENSE' }, select: { id: true } });
|
// Codes d'EXPENSES (exemples)
|
||||||
if (!expenseCodes.length) throw new Error('Need EXPENSE bank codes');
|
const BANKS = ['G517', 'G503', 'G502', 'G202'] as const;
|
||||||
|
|
||||||
const timesheets = await prisma.timesheets.findMany({ select: { id: true } });
|
// Précharger les bank codes
|
||||||
if (!timesheets.length) {
|
const bcRows = await prisma.bankCodes.findMany({
|
||||||
console.warn('No timesheets found; aborting expenses seed.');
|
where: { bank_code: { in: BANKS as unknown as string[] } },
|
||||||
|
select: { id: true, bank_code: true },
|
||||||
|
});
|
||||||
|
const bcMap = new Map(bcRows.map(c => [c.bank_code, c.id]));
|
||||||
|
for (const c of BANKS) {
|
||||||
|
if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Employés
|
||||||
|
const employees = await prisma.employees.findMany({ select: { id: true } });
|
||||||
|
if (!employees.length) {
|
||||||
|
console.warn('Aucun employé — rien à insérer.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5 expenses distribuées aléatoirement parmi les employés (via timesheets)
|
// Liste des lundis (courant + 4 précédents)
|
||||||
for (let i = 0; i < 5; i++) {
|
const mondayThisWeek = mondayOfThisWeekUTC();
|
||||||
const ts = timesheets[Math.floor(Math.random() * timesheets.length)];
|
const mondays: Date[] = [];
|
||||||
const bc = expenseCodes[i % expenseCodes.length];
|
if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
|
||||||
|
for (let n = 1; n <= WEEKS_BACK; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const monday of mondays) {
|
||||||
|
const weekDays = weekDatesMonToFri(monday);
|
||||||
|
const friday = weekDays[4];
|
||||||
|
|
||||||
|
for (const e of employees) {
|
||||||
|
// Upsert timesheet pour CETTE semaine/employee
|
||||||
|
const ts = await getOrCreateTimesheet(e.id, monday);
|
||||||
|
|
||||||
|
// Idempotence: si déjà au moins une expense L→V, on skip la semaine
|
||||||
|
const already = await prisma.expenses.findFirst({
|
||||||
|
where: { timesheet_id: ts.id, date: { gte: monday, lte: friday } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (already) continue;
|
||||||
|
|
||||||
|
// 1 à 3 expenses (jours distincts)
|
||||||
|
const count = rndInt(1, 3);
|
||||||
|
const dayIndexes = [0, 1, 2, 3, 4].sort(() => Math.random() - 0.5).slice(0, count);
|
||||||
|
|
||||||
|
for (const idx of dayIndexes) {
|
||||||
|
const date = weekDays[idx];
|
||||||
|
const code = BANKS[rndInt(0, BANKS.length - 1)];
|
||||||
|
const bank_code_id = bcMap.get(code)!;
|
||||||
|
|
||||||
|
// Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard
|
||||||
|
let amount: string = '0.00';
|
||||||
|
let mileage: string = '0.00';
|
||||||
|
switch (code) {
|
||||||
|
case 'G503': // kilométrage
|
||||||
|
mileage = to2(rndAmount(1000, 7500)); // 10.00 à 75.00
|
||||||
|
break;
|
||||||
|
case 'G502': // per_diem
|
||||||
|
amount = to2(rndAmount(1500, 3000)); // 15.00 à 30.00
|
||||||
|
break;
|
||||||
|
case 'G202': // on_call /prime de garde
|
||||||
|
amount = to2(rndAmount(2000, 15000)); // 20.00 à 150.00
|
||||||
|
break;
|
||||||
|
case 'G517': // expenses
|
||||||
|
default:
|
||||||
|
amount = to2(rndAmount(500, 5000)); // 5.00 à 50.00
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.expenses.create({
|
await prisma.expenses.create({
|
||||||
data: {
|
data: {
|
||||||
timesheet_id: ts.id,
|
timesheet_id: ts.id,
|
||||||
bank_code_id: bc.id,
|
bank_code_id,
|
||||||
date: daysAgo(3 + i),
|
date,
|
||||||
amount: (50 + i * 10).toFixed(2),
|
amount,
|
||||||
attachement: null,
|
mileage,
|
||||||
description: `Expense #${i + 1}`,
|
attachment: null,
|
||||||
is_approved: Math.random() < 0.5,
|
comment: `Expense ${code} (emp ${e.id})`,
|
||||||
supervisor_comment: Math.random() < 0.3 ? 'OK' : null,
|
is_approved: Math.random() < 0.65,
|
||||||
|
supervisor_comment: Math.random() < 0.25 ? 'OK' : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = await prisma.expenses.count();
|
const total = await prisma.expenses.count();
|
||||||
console.log(`✓ Expenses: ${total} total rows`);
|
console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (sem courante + ${WEEKS_BACK} précédentes, L→V uniquement, montants en quarts de dollar)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
main().finally(() => prisma.$disconnect());
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
// 13-expenses-archive.ts
|
// 13-expenses-archive.ts
|
||||||
import { PrismaClient, Expenses } from '@prisma/client';
|
import { PrismaClient, Expenses } from '@prisma/client';
|
||||||
|
|
||||||
|
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
|
||||||
|
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function daysAgo(n:number) {
|
function daysAgo(n:number) {
|
||||||
|
|
@ -17,7 +22,7 @@ async function main() {
|
||||||
// ✅ typer pour éviter never[]
|
// ✅ typer pour éviter never[]
|
||||||
const created: Expenses[] = [];
|
const created: Expenses[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
const ts = timesheets[i % timesheets.length];
|
const ts = timesheets[i % timesheets.length];
|
||||||
const bc = expenseCodes[i % expenseCodes.length];
|
const bc = expenseCodes[i % expenseCodes.length];
|
||||||
|
|
||||||
|
|
@ -27,8 +32,8 @@ async function main() {
|
||||||
bank_code_id: bc.id,
|
bank_code_id: bc.id,
|
||||||
date: daysAgo(60 + i),
|
date: daysAgo(60 + i),
|
||||||
amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string
|
amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string
|
||||||
attachement: null,
|
attachment: null,
|
||||||
description: `Old expense #${i + 1}`,
|
comment: `Old expense #${i + 1}`,
|
||||||
is_approved: true,
|
is_approved: true,
|
||||||
supervisor_comment: null,
|
supervisor_comment: null,
|
||||||
},
|
},
|
||||||
|
|
@ -45,8 +50,8 @@ async function main() {
|
||||||
bank_code_id: e.bank_code_id,
|
bank_code_id: e.bank_code_id,
|
||||||
date: e.date,
|
date: e.date,
|
||||||
amount: e.amount,
|
amount: e.amount,
|
||||||
attachement: e.attachement,
|
attachment: e.attachment,
|
||||||
description: e.description,
|
comment: e.comment,
|
||||||
is_approved: e.is_approved,
|
is_approved: e.is_approved,
|
||||||
supervisor_comment: e.supervisor_comment,
|
supervisor_comment: e.supervisor_comment,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ model Users {
|
||||||
first_name String
|
first_name String
|
||||||
last_name String
|
last_name String
|
||||||
email String @unique
|
email String @unique
|
||||||
phone_number Int @unique
|
phone_number String @unique
|
||||||
residence String?
|
residence String?
|
||||||
role Roles @default(GUEST)
|
role Roles @default(GUEST)
|
||||||
|
|
||||||
|
|
@ -28,6 +28,7 @@ model Users {
|
||||||
oauth_sessions OAuthSessions[] @relation("UserOAuthSessions")
|
oauth_sessions OAuthSessions[] @relation("UserOAuthSessions")
|
||||||
employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive")
|
employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive")
|
||||||
customer_archive CustomersArchive[] @relation("UserToCustomersToArchive")
|
customer_archive CustomersArchive[] @relation("UserToCustomersToArchive")
|
||||||
|
preferences Preferences? @relation("UserPreferences")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +37,9 @@ model Employees {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
user Users @relation("UserEmployee", fields: [user_id], references: [id])
|
user Users @relation("UserEmployee", fields: [user_id], references: [id])
|
||||||
user_id String @unique @db.Uuid
|
user_id String @unique @db.Uuid
|
||||||
|
supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id])
|
||||||
|
supervisor_id Int?
|
||||||
|
|
||||||
external_payroll_id Int
|
external_payroll_id Int
|
||||||
company_code Int
|
company_code Int
|
||||||
first_work_day DateTime @db.Date
|
first_work_day DateTime @db.Date
|
||||||
|
|
@ -43,14 +47,13 @@ model Employees {
|
||||||
job_title String?
|
job_title String?
|
||||||
is_supervisor Boolean @default(false)
|
is_supervisor Boolean @default(false)
|
||||||
|
|
||||||
supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id])
|
|
||||||
supervisor_id Int?
|
|
||||||
crew Employees[] @relation("EmployeeSupervisor")
|
|
||||||
|
|
||||||
|
crew Employees[] @relation("EmployeeSupervisor")
|
||||||
archive EmployeesArchive[] @relation("EmployeeToArchive")
|
archive EmployeesArchive[] @relation("EmployeeToArchive")
|
||||||
timesheet Timesheets[] @relation("TimesheetEmployee")
|
timesheet Timesheets[] @relation("TimesheetEmployee")
|
||||||
leave_request LeaveRequests[] @relation("LeaveRequestEmployee")
|
leave_request LeaveRequests[] @relation("LeaveRequestEmployee")
|
||||||
supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive")
|
supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive")
|
||||||
|
schedule_presets SchedulePresets[] @relation("SchedulePreset")
|
||||||
|
|
||||||
@@map("employees")
|
@@map("employees")
|
||||||
}
|
}
|
||||||
|
|
@ -59,10 +62,12 @@ model EmployeesArchive {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id])
|
employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id])
|
||||||
employee_id Int
|
employee_id Int
|
||||||
archived_at DateTime @default(now())
|
|
||||||
|
|
||||||
user_id String @db.Uuid
|
user_id String @db.Uuid
|
||||||
user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id])
|
user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id])
|
||||||
|
supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id])
|
||||||
|
supervisor_id Int?
|
||||||
|
|
||||||
|
archived_at DateTime @default(now())
|
||||||
first_name String
|
first_name String
|
||||||
last_name String
|
last_name String
|
||||||
job_title String?
|
job_title String?
|
||||||
|
|
@ -71,8 +76,6 @@ model EmployeesArchive {
|
||||||
company_code Int
|
company_code Int
|
||||||
first_work_day DateTime @db.Date
|
first_work_day DateTime @db.Date
|
||||||
last_work_day DateTime @db.Date
|
last_work_day DateTime @db.Date
|
||||||
supervisor_id Int?
|
|
||||||
supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id])
|
|
||||||
|
|
||||||
@@map("employees_archive")
|
@@map("employees_archive")
|
||||||
}
|
}
|
||||||
|
|
@ -92,10 +95,10 @@ model CustomersArchive {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id])
|
customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id])
|
||||||
customer_id Int
|
customer_id Int
|
||||||
archived_at DateTime @default(now())
|
|
||||||
user_id String @db.Uuid
|
|
||||||
user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id])
|
user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id])
|
||||||
|
user_id String @db.Uuid
|
||||||
|
|
||||||
|
archived_at DateTime @default(now())
|
||||||
invoice_id Int? @unique
|
invoice_id Int? @unique
|
||||||
|
|
||||||
@@map("customers_archive")
|
@@map("customers_archive")
|
||||||
|
|
@ -105,16 +108,20 @@ model LeaveRequests {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id])
|
employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id])
|
||||||
employee_id Int
|
employee_id Int
|
||||||
bank_code BankCodes? @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id])
|
bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id])
|
||||||
bank_code_id Int
|
bank_code_id Int
|
||||||
leave_type LeaveTypes
|
|
||||||
start_date_time DateTime @db.Date
|
|
||||||
end_date_time DateTime? @db.Date
|
|
||||||
comment String
|
comment String
|
||||||
|
date DateTime @db.Date
|
||||||
|
payable_hours Decimal? @db.Decimal(5, 2)
|
||||||
|
requested_hours Decimal? @db.Decimal(5, 2)
|
||||||
approval_status LeaveApprovalStatus @default(PENDING)
|
approval_status LeaveApprovalStatus @default(PENDING)
|
||||||
|
leave_type LeaveTypes
|
||||||
|
|
||||||
archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive")
|
archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive")
|
||||||
|
|
||||||
|
@@unique([employee_id, leave_type, date], name: "leave_per_employee_date")
|
||||||
|
@@index([employee_id, date])
|
||||||
@@map("leave_requests")
|
@@map("leave_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,14 +129,18 @@ model LeaveRequestsArchive {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id])
|
leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id])
|
||||||
leave_request_id Int
|
leave_request_id Int
|
||||||
|
|
||||||
archived_at DateTime @default(now())
|
archived_at DateTime @default(now())
|
||||||
employee_id Int
|
employee_id Int
|
||||||
leave_type LeaveTypes
|
date DateTime @db.Date
|
||||||
start_date_time DateTime @db.Date
|
payable_hours Decimal? @db.Decimal(5, 2)
|
||||||
end_date_time DateTime? @db.Date
|
requested_hours Decimal? @db.Decimal(5, 2)
|
||||||
comment String
|
comment String
|
||||||
|
leave_type LeaveTypes
|
||||||
approval_status LeaveApprovalStatus
|
approval_status LeaveApprovalStatus
|
||||||
|
|
||||||
|
@@unique([leave_request_id])
|
||||||
|
@@index([employee_id, date])
|
||||||
@@map("leave_requests_archive")
|
@@map("leave_requests_archive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,10 +148,10 @@ model LeaveRequestsArchive {
|
||||||
view PayPeriods {
|
view PayPeriods {
|
||||||
pay_year Int
|
pay_year Int
|
||||||
pay_period_no Int
|
pay_period_no Int
|
||||||
|
label String
|
||||||
payday DateTime @db.Date
|
payday DateTime @db.Date
|
||||||
period_start DateTime @db.Date
|
period_start DateTime @db.Date
|
||||||
period_end DateTime @db.Date
|
period_end DateTime @db.Date
|
||||||
label String
|
|
||||||
|
|
||||||
@@map("pay_period")
|
@@map("pay_period")
|
||||||
}
|
}
|
||||||
|
|
@ -149,12 +160,15 @@ model Timesheets {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id])
|
employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id])
|
||||||
employee_id Int
|
employee_id Int
|
||||||
|
|
||||||
|
start_date DateTime @db.Date
|
||||||
is_approved Boolean @default(false)
|
is_approved Boolean @default(false)
|
||||||
|
|
||||||
shift Shifts[] @relation("ShiftTimesheet")
|
shift Shifts[] @relation("ShiftTimesheet")
|
||||||
expense Expenses[] @relation("ExpensesTimesheet")
|
expense Expenses[] @relation("ExpensesTimesheet")
|
||||||
archive TimesheetsArchive[] @relation("TimesheetsToArchive")
|
archive TimesheetsArchive[] @relation("TimesheetsToArchive")
|
||||||
|
|
||||||
|
@@unique([employee_id, start_date], name: "employee_id_start_date")
|
||||||
@@map("timesheets")
|
@@map("timesheets")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,24 +176,71 @@ model TimesheetsArchive {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id])
|
timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id])
|
||||||
timesheet_id Int
|
timesheet_id Int
|
||||||
archive_at DateTime @default(now())
|
|
||||||
employee_id Int
|
employee_id Int
|
||||||
is_approved Boolean
|
is_approved Boolean
|
||||||
|
archive_at DateTime @default(now())
|
||||||
|
|
||||||
@@map("timesheets_archive")
|
@@map("timesheets_archive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
model SchedulePresets {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
employee Employees @relation("SchedulePreset", fields: [employee_id], references: [id])
|
||||||
|
employee_id Int
|
||||||
|
|
||||||
|
name String
|
||||||
|
is_default Boolean @default(false)
|
||||||
|
|
||||||
|
shifts SchedulePresetShifts[] @relation("SchedulePresetShiftsSchedulePreset")
|
||||||
|
|
||||||
|
@@unique([employee_id, name], name: "unique_preset_name_per_employee")
|
||||||
|
@@map("schedule_presets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SchedulePresetShifts {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
preset SchedulePresets @relation("SchedulePresetShiftsSchedulePreset",fields: [preset_id], references: [id])
|
||||||
|
preset_id Int
|
||||||
|
bank_code BankCodes @relation("SchedulePresetShiftsBankCodes",fields: [bank_code_id], references: [id])
|
||||||
|
bank_code_id Int
|
||||||
|
|
||||||
|
sort_order Int
|
||||||
|
start_time DateTime @db.Time(0)
|
||||||
|
end_time DateTime @db.Time(0)
|
||||||
|
is_remote Boolean @default(false)
|
||||||
|
week_day Weekday
|
||||||
|
|
||||||
|
@@unique([preset_id, week_day, sort_order], name: "unique_preset_shift_per_day_order")
|
||||||
|
@@index([preset_id, week_day])
|
||||||
|
@@map("schedule_preset_shifts")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
model Shifts {
|
model Shifts {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id])
|
timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id])
|
||||||
timesheet_id Int
|
timesheet_id Int
|
||||||
bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id])
|
bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id])
|
||||||
bank_code_id Int
|
bank_code_id Int
|
||||||
description String?
|
|
||||||
date DateTime @db.Date
|
date DateTime @db.Date
|
||||||
start_time DateTime @db.Time(0)
|
start_time DateTime @db.Time(0)
|
||||||
end_time DateTime @db.Time(0)
|
end_time DateTime @db.Time(0)
|
||||||
is_approved Boolean @default(false)
|
is_approved Boolean @default(false)
|
||||||
|
is_remote Boolean @default(false)
|
||||||
|
comment String?
|
||||||
|
|
||||||
archive ShiftsArchive[] @relation("ShiftsToArchive")
|
archive ShiftsArchive[] @relation("ShiftsToArchive")
|
||||||
|
|
||||||
|
|
@ -190,13 +251,14 @@ model ShiftsArchive {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id])
|
shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id])
|
||||||
shift_id Int
|
shift_id Int
|
||||||
archive_at DateTime @default(now())
|
|
||||||
timesheet_id Int
|
|
||||||
bank_code_id Int
|
|
||||||
description String?
|
|
||||||
date DateTime @db.Date
|
date DateTime @db.Date
|
||||||
start_time DateTime @db.Time(0)
|
start_time DateTime @db.Time(0)
|
||||||
end_time DateTime @db.Time(0)
|
end_time DateTime @db.Time(0)
|
||||||
|
timesheet_id Int
|
||||||
|
bank_code_id Int
|
||||||
|
comment String?
|
||||||
|
archive_at DateTime @default(now())
|
||||||
|
|
||||||
@@map("shifts_archive")
|
@@map("shifts_archive")
|
||||||
}
|
}
|
||||||
|
|
@ -211,6 +273,7 @@ model BankCodes {
|
||||||
shifts Shifts[] @relation("ShiftBankCodes")
|
shifts Shifts[] @relation("ShiftBankCodes")
|
||||||
expenses Expenses[] @relation("ExpenseBankCodes")
|
expenses Expenses[] @relation("ExpenseBankCodes")
|
||||||
leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes")
|
leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes")
|
||||||
|
SchedulePresetShifts SchedulePresetShifts[] @relation("SchedulePresetShiftsBankCodes")
|
||||||
|
|
||||||
@@map("bank_codes")
|
@@map("bank_codes")
|
||||||
}
|
}
|
||||||
|
|
@ -221,12 +284,15 @@ model Expenses {
|
||||||
timesheet_id Int
|
timesheet_id Int
|
||||||
bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id])
|
bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id])
|
||||||
bank_code_id Int
|
bank_code_id Int
|
||||||
|
attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull)
|
||||||
|
attachment Int?
|
||||||
|
|
||||||
date DateTime @db.Date
|
date DateTime @db.Date
|
||||||
amount Decimal @db.Money
|
amount Decimal @db.Money
|
||||||
attachement String?
|
mileage Decimal? @db.Decimal(12,2)
|
||||||
description String?
|
comment String
|
||||||
is_approved Boolean @default(false)
|
|
||||||
supervisor_comment String?
|
supervisor_comment String?
|
||||||
|
is_approved Boolean @default(false)
|
||||||
|
|
||||||
archive ExpensesArchive[] @relation("ExpensesToArchive")
|
archive ExpensesArchive[] @relation("ExpensesToArchive")
|
||||||
|
|
||||||
|
|
@ -237,13 +303,16 @@ model ExpensesArchive {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id])
|
expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id])
|
||||||
expense_id Int
|
expense_id Int
|
||||||
|
attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull)
|
||||||
|
attachment Int?
|
||||||
|
|
||||||
timesheet_id Int
|
timesheet_id Int
|
||||||
archived_at DateTime @default(now())
|
archived_at DateTime @default(now())
|
||||||
bank_code_id Int
|
bank_code_id Int
|
||||||
date DateTime @db.Date
|
date DateTime @db.Date
|
||||||
amount Decimal @db.Money
|
amount Decimal? @db.Money
|
||||||
attachement String?
|
mileage Decimal? @db.Decimal(12,2)
|
||||||
description String?
|
comment String?
|
||||||
is_approved Boolean
|
is_approved Boolean
|
||||||
supervisor_comment String?
|
supervisor_comment String?
|
||||||
|
|
||||||
|
|
@ -283,8 +352,9 @@ model Blobs {
|
||||||
|
|
||||||
model Attachments {
|
model Attachments {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
sha256 String @db.Char(64)
|
|
||||||
blob Blobs @relation("AttachmnentBlob",fields: [sha256], references: [sha256], onUpdate: Cascade)
|
blob Blobs @relation("AttachmnentBlob",fields: [sha256], references: [sha256], onUpdate: Cascade)
|
||||||
|
sha256 String @db.Char(64)
|
||||||
|
|
||||||
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc
|
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc
|
||||||
owner_id String //expense_id, employee_id, etc
|
owner_id String //expense_id, employee_id, etc
|
||||||
original_name String
|
original_name String
|
||||||
|
|
@ -293,6 +363,9 @@ model Attachments {
|
||||||
created_by String
|
created_by String
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
expenses Expenses[] @relation("ExpenseAttachment")
|
||||||
|
expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment")
|
||||||
|
|
||||||
AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment")
|
AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment")
|
||||||
|
|
||||||
@@index([owner_type, owner_id, created_at])
|
@@index([owner_type, owner_id, created_at])
|
||||||
|
|
@ -315,6 +388,19 @@ model AttachmentVariants {
|
||||||
@@map("attachment_variants")
|
@@map("attachment_variants")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Preferences {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user Users @relation("UserPreferences", fields: [user_id], references: [id])
|
||||||
|
user_id String @unique @db.Uuid
|
||||||
|
|
||||||
|
notifications Int @default(0)
|
||||||
|
dark_mode Int @default(0)
|
||||||
|
lang_switch Int @default(0)
|
||||||
|
lefty_mode Int @default(0)
|
||||||
|
|
||||||
|
@@map("preferences")
|
||||||
|
}
|
||||||
|
|
||||||
enum AttachmentStatus {
|
enum AttachmentStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
DELETED
|
DELETED
|
||||||
|
|
@ -347,6 +433,7 @@ enum LeaveTypes {
|
||||||
PARENTAL // maternite/paternite/adoption
|
PARENTAL // maternite/paternite/adoption
|
||||||
LEGAL // obligations legales comme devoir de juree
|
LEGAL // obligations legales comme devoir de juree
|
||||||
WEDDING // mariage
|
WEDDING // mariage
|
||||||
|
HOLIDAY // férier
|
||||||
|
|
||||||
@@map("leave_types")
|
@@map("leave_types")
|
||||||
}
|
}
|
||||||
|
|
@ -360,3 +447,13 @@ enum LeaveApprovalStatus {
|
||||||
|
|
||||||
@@map("leave_approval_status")
|
@@map("leave_approval_status")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Weekday {
|
||||||
|
SUN
|
||||||
|
MON
|
||||||
|
TUE
|
||||||
|
WED
|
||||||
|
THU
|
||||||
|
FRI
|
||||||
|
SAT
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { ArchivalModule } from './modules/archival/archival.module';
|
import { ArchivalModule } from './modules/archival/archival.module';
|
||||||
import { AuthenticationModule } from './modules/authentication/auth.module';
|
import { AuthenticationModule } from './modules/authentication/auth.module';
|
||||||
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
|
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
|
||||||
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
|
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
|
||||||
import { CsvExportModule } from './modules/exports/csv-exports.module';
|
// import { CsvExportModule } from './modules/exports/csv-exports.module';
|
||||||
import { CustomersModule } from './modules/customers/customers.module';
|
import { CustomersModule } from './modules/customers/customers.module';
|
||||||
import { EmployeesModule } from './modules/employees/employees.module';
|
import { EmployeesModule } from './modules/employees/employees.module';
|
||||||
import { ExpensesModule } from './modules/expenses/expenses.module';
|
import { ExpensesModule } from './modules/expenses/expenses.module';
|
||||||
|
|
@ -16,12 +16,17 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
||||||
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
|
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
|
||||||
import { OvertimeService } from './modules/business-logics/services/overtime.service';
|
import { OvertimeService } from './modules/business-logics/services/overtime.service';
|
||||||
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
|
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
|
||||||
|
import { PreferencesModule } from './modules/preferences/preferences.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ShiftsModule } from './modules/shifts/shifts.module';
|
import { ShiftsModule } from './modules/shifts/shifts.module';
|
||||||
import { TimesheetsModule } from './modules/timesheets/timesheets.module';
|
import { TimesheetsModule } from './modules/timesheets/timesheets.module';
|
||||||
import { UsersModule } from './modules/users-management/users.module';
|
import { UsersModule } from './modules/users-management/users.module';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||||
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
|
import { ValidationError } from 'class-validator';
|
||||||
|
import { SchedulePresetsModule } from './modules/schedule-presets/schedule-presets.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -30,7 +35,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||||
BankCodesModule,
|
BankCodesModule,
|
||||||
BusinessLogicsModule,
|
BusinessLogicsModule,
|
||||||
ConfigModule.forRoot({isGlobal: true}),
|
ConfigModule.forRoot({isGlobal: true}),
|
||||||
CsvExportModule,
|
// CsvExportModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
EmployeesModule,
|
EmployeesModule,
|
||||||
ExpensesModule,
|
ExpensesModule,
|
||||||
|
|
@ -39,13 +44,38 @@ import { ConfigModule } from '@nestjs/config';
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
OauthSessionsModule,
|
OauthSessionsModule,
|
||||||
PayperiodsModule,
|
PayperiodsModule,
|
||||||
|
PreferencesModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(), //cronjobs
|
||||||
|
SchedulePresetsModule,
|
||||||
ShiftsModule,
|
ShiftsModule,
|
||||||
TimesheetsModule,
|
TimesheetsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, HealthController],
|
controllers: [AppController, HealthController],
|
||||||
providers: [AppService, OvertimeService],
|
providers: [
|
||||||
|
AppService,
|
||||||
|
OvertimeService,
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: HttpExceptionFilter
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_PIPE,
|
||||||
|
useValue: new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
exceptionFactory: (errors: ValidationError[] = [])=> {
|
||||||
|
const messages = errors.flatMap((e)=> Object.values(e.constraints ?? {}));
|
||||||
|
return new BadRequestException({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: messages.length ? messages : errors,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
24
src/common/filters/http-exception.filter.ts
Normal file
24
src/common/filters/http-exception.filter.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common";
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Catch(HttpException)
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: HttpException, host: ArgumentsHost) {
|
||||||
|
const http_context = host.switchToHttp();
|
||||||
|
const response = http_context.getResponse<Response>();
|
||||||
|
const request = http_context.getRequest<Request>();
|
||||||
|
const http_status = exception.getStatus();
|
||||||
|
|
||||||
|
const exception_response = exception.getResponse();
|
||||||
|
const normalized = typeof exception_response === 'string'
|
||||||
|
? { message: exception_response }
|
||||||
|
: (exception_response as Record<string, unknown>);
|
||||||
|
const response_body = {
|
||||||
|
statusCode: http_status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
...normalized,
|
||||||
|
};
|
||||||
|
response.status(http_status).json(response_body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,13 @@ export function getYearStart(date:Date): Date {
|
||||||
return new Date(date.getFullYear(),0,1,0,0,0,0);
|
return new Date(date.getFullYear(),0,1,0,0,0,0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCurrentWeek(): { start_date_week: Date; end_date_week: Date } {
|
||||||
|
const now = new Date();
|
||||||
|
const start_date_week = getWeekStart(now, 0);
|
||||||
|
const end_date_week = getWeekEnd(start_date_week);
|
||||||
|
return { start_date_week, end_date_week };
|
||||||
|
}
|
||||||
|
|
||||||
//cloning methods (helps with notify for overtime in a single day)
|
//cloning methods (helps with notify for overtime in a single day)
|
||||||
// export function toDateOnly(day: Date): Date {
|
// export function toDateOnly(day: Date): Date {
|
||||||
// const d = new Date(day);
|
// const d = new Date(day);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { ATT_TMP_DIR } from './config/attachment.config'; // log to be removed p
|
||||||
|
|
||||||
import { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
|
import { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
|
||||||
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
|
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './common/guards/roles.guard';
|
import { RolesGuard } from './common/guards/roles.guard';
|
||||||
import { OwnershipGuard } from './common/guards/ownership.guard';
|
import { OwnershipGuard } from './common/guards/ownership.guard';
|
||||||
|
|
@ -25,8 +24,6 @@ async function bootstrap() {
|
||||||
|
|
||||||
const reflector = app.get(Reflector); //setup Reflector for Roles()
|
const reflector = app.get(Reflector); //setup Reflector for Roles()
|
||||||
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }));
|
|
||||||
app.useGlobalGuards(
|
app.useGlobalGuards(
|
||||||
// new JwtAuthGuard(reflector), //Authentification JWT
|
// new JwtAuthGuard(reflector), //Authentification JWT
|
||||||
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
|
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { EmployeesModule } from "../employees/employees.module";
|
||||||
LeaveRequestsArchiveController,
|
LeaveRequestsArchiveController,
|
||||||
ShiftsArchiveController,
|
ShiftsArchiveController,
|
||||||
TimesheetsArchiveController,
|
TimesheetsArchiveController,
|
||||||
]
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
export class ArchivalModule {}
|
export class ArchivalModule {}
|
||||||
|
|
@ -2,20 +2,20 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
import { EmployeesArchive, Roles as RoleEnum } from '@prisma/client';
|
import { EmployeesArchive, Roles as RoleEnum } from '@prisma/client';
|
||||||
import { EmployeesService } from "src/modules/employees/services/employees.service";
|
import { EmployeesArchivalService } from "src/modules/employees/services/employees-archival.service";
|
||||||
|
|
||||||
@ApiTags('Employee Archives')
|
@ApiTags('Employee Archives')
|
||||||
// @UseGuards()
|
// @UseGuards()
|
||||||
@Controller('archives/employees')
|
@Controller('archives/employees')
|
||||||
export class EmployeesArchiveController {
|
export class EmployeesArchiveController {
|
||||||
constructor(private readonly employeesService: EmployeesService) {}
|
constructor(private readonly employeesArchiveService: EmployeesArchivalService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
@ApiOperation({ summary: 'List of archived employees'})
|
@ApiOperation({ summary: 'List of archived employees'})
|
||||||
@ApiResponse({ status: 200, description: 'List of archived employees', isArray: true })
|
@ApiResponse({ status: 200, description: 'List of archived employees', isArray: true })
|
||||||
async findAllArchived(): Promise<EmployeesArchive[]> {
|
async findAllArchived(): Promise<EmployeesArchive[]> {
|
||||||
return this.employeesService.findAllArchived();
|
return this.employeesArchiveService.findAllArchived();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
|
@ -24,7 +24,7 @@ export class EmployeesArchiveController {
|
||||||
@ApiResponse({ status: 200, description: 'Archived employee found'})
|
@ApiResponse({ status: 200, description: 'Archived employee found'})
|
||||||
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<EmployeesArchive> {
|
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<EmployeesArchive> {
|
||||||
try{
|
try{
|
||||||
return await this.employeesService.findOneArchived(id);
|
return await this.employeesArchiveService.findOneArchived(id);
|
||||||
}catch {
|
}catch {
|
||||||
throw new NotFoundException(`Archived employee #${id} not found`);
|
throw new NotFoundException(`Archived employee #${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import { UseGuards, Controller, Get, Param, ParseIntPipe, NotFoundException } fr
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||||
import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client";
|
import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client";
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service";
|
import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service";
|
||||||
|
|
||||||
@ApiTags('Expense Archives')
|
@ApiTags('Expense Archives')
|
||||||
// @UseGuards()
|
// @UseGuards()
|
||||||
@Controller('archives/expenses')
|
@Controller('archives/expenses')
|
||||||
export class ExpensesArchiveController {
|
export class ExpensesArchiveController {
|
||||||
constructor(private readonly expensesService: ExpensesQueryService) {}
|
constructor(private readonly expensesService: ExpensesArchivalService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,7 @@
|
||||||
import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } from "@nestjs/common";
|
import { Controller } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client";
|
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
|
||||||
import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto";
|
|
||||||
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service";
|
|
||||||
|
|
||||||
@ApiTags('LeaveRequests Archives')
|
@ApiTags('LeaveRequests Archives')
|
||||||
// @UseGuards()
|
// @UseGuards()
|
||||||
@Controller('archives/leaveRequests')
|
@Controller('archives/leaveRequests')
|
||||||
export class LeaveRequestsArchiveController {
|
export class LeaveRequestsArchiveController {}
|
||||||
constructor(private readonly leaveRequestsService: LeaveRequestsService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
|
||||||
@ApiOperation({ summary: 'List of archived leaveRequests'})
|
|
||||||
@ApiResponse({ status: 200, description: 'List of archived leaveRequests', isArray: true })
|
|
||||||
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
|
|
||||||
return this.leaveRequestsService.findAllArchived();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
|
||||||
@ApiOperation({ summary: 'Fetch leaveRequest in archives with its Id'})
|
|
||||||
@ApiResponse({ status: 200, description: 'Archived leaveRequest found'})
|
|
||||||
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<LeaveRequestViewDto> {
|
|
||||||
try{
|
|
||||||
return await this.leaveRequestsService.findOneArchived(id);
|
|
||||||
}catch {
|
|
||||||
throw new NotFoundException(`Archived leaveRequest #${id} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,13 +2,13 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client";
|
import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client";
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service";
|
import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service";
|
||||||
|
|
||||||
@ApiTags('Shift Archives')
|
@ApiTags('Shift Archives')
|
||||||
// @UseGuards()
|
// @UseGuards()
|
||||||
@Controller('archives/shifts')
|
@Controller('archives/shifts')
|
||||||
export class ShiftsArchiveController {
|
export class ShiftsArchiveController {
|
||||||
constructor(private readonly shiftsService:ShiftsQueryService) {}
|
constructor(private readonly shiftsService: ShiftsArchivalService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
import { TimesheetsArchive, Roles as RoleEnum } from '@prisma/client';
|
import { TimesheetsArchive, Roles as RoleEnum } from '@prisma/client';
|
||||||
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service";
|
import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service";
|
||||||
|
|
||||||
@ApiTags('Timesheet Archives')
|
@ApiTags('Timesheet Archives')
|
||||||
// @UseGuards()
|
// @UseGuards()
|
||||||
@Controller('archives/timesheets')
|
@Controller('archives/timesheets')
|
||||||
export class TimesheetsArchiveController {
|
export class TimesheetsArchiveController {
|
||||||
constructor(private readonly timesheetsService: TimesheetsQueryService) {}
|
constructor(private readonly timesheetsService: TimesheetArchiveService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service";
|
import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service";
|
||||||
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service";
|
import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service";
|
||||||
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service";
|
import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service";
|
||||||
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ArchivalService {
|
export class ArchivalService {
|
||||||
private readonly logger = new Logger(ArchivalService.name);
|
private readonly logger = new Logger(ArchivalService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly timesheetsService: TimesheetsQueryService,
|
private readonly timesheetsService: TimesheetArchiveService,
|
||||||
private readonly expensesService: ExpensesQueryService,
|
private readonly expensesService: ExpensesArchivalService,
|
||||||
private readonly shiftsService: ShiftsQueryService,
|
private readonly shiftsService: ShiftsArchivalService,
|
||||||
private readonly leaveRequestsService: LeaveRequestsService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00
|
@Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00
|
||||||
|
|
@ -31,7 +29,7 @@ export class ArchivalService {
|
||||||
await this.timesheetsService.archiveOld();
|
await this.timesheetsService.archiveOld();
|
||||||
await this.expensesService.archiveOld();
|
await this.expensesService.archiveOld();
|
||||||
await this.shiftsService.archiveOld();
|
await this.shiftsService.archiveOld();
|
||||||
await this.leaveRequestsService.archiveExpired();
|
// await this.leaveRequestsService.archiveExpired();
|
||||||
this.logger.log('archivation process done');
|
this.logger.log('archivation process done');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('an error occured during archivation process ', err);
|
this.logger.error('an error occured during archivation process ', err);
|
||||||
|
|
|
||||||
|
|
@ -7,40 +7,43 @@ import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOperation, ApiResponse }
|
||||||
@Controller('bank-codes')
|
@Controller('bank-codes')
|
||||||
export class BankCodesControllers {
|
export class BankCodesControllers {
|
||||||
constructor(private readonly bankCodesService: BankCodesService) {}
|
constructor(private readonly bankCodesService: BankCodesService) {}
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
// Deprecated or unused methods
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
|
||||||
@Post()
|
// @Post()
|
||||||
@ApiOperation({ summary: 'Create a new bank code' })
|
// @ApiOperation({ summary: 'Create a new bank code' })
|
||||||
@ApiResponse({ status: 201, description: 'Bank code successfully created.' })
|
// @ApiResponse({ status: 201, description: 'Bank code successfully created.' })
|
||||||
@ApiBadRequestResponse({ description: 'Invalid input data.' })
|
// @ApiBadRequestResponse({ description: 'Invalid input data.' })
|
||||||
create(@Body() dto: CreateBankCodeDto) {
|
// create(@Body() dto: CreateBankCodeDto) {
|
||||||
return this.bankCodesService.create(dto);
|
// return this.bankCodesService.create(dto);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Get()
|
// @Get()
|
||||||
@ApiOperation({ summary: 'Retrieve all bank codes' })
|
// @ApiOperation({ summary: 'Retrieve all bank codes' })
|
||||||
@ApiResponse({ status: 200, description: 'List of bank codes.' })
|
// @ApiResponse({ status: 200, description: 'List of bank codes.' })
|
||||||
findAll() {
|
// findAll() {
|
||||||
return this.bankCodesService.findAll();
|
// return this.bankCodesService.findAll();
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Get(':id')
|
// @Get(':id')
|
||||||
@ApiOperation({ summary: 'Retrieve a bank code by its ID' })
|
// @ApiOperation({ summary: 'Retrieve a bank code by its ID' })
|
||||||
@ApiNotFoundResponse({ description: 'Bank code not found.' })
|
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||||
findOne(@Param('id', ParseIntPipe) id: number){
|
// findOne(@Param('id', ParseIntPipe) id: number){
|
||||||
return this.bankCodesService.findOne(id);
|
// return this.bankCodesService.findOne(id);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Patch(':id')
|
// @Patch(':id')
|
||||||
@ApiOperation({ summary: 'Update an existing bank code' })
|
// @ApiOperation({ summary: 'Update an existing bank code' })
|
||||||
@ApiNotFoundResponse({ description: 'Bank code not found.' })
|
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||||
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) {
|
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) {
|
||||||
return this.bankCodesService.update(id, dto)
|
// return this.bankCodesService.update(id, dto)
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Delete(':id')
|
// @Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete a bank code' })
|
// @ApiOperation({ summary: 'Delete a bank code' })
|
||||||
@ApiNotFoundResponse({ description: 'Bank code not found.' })
|
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||||
remove(@Param('id', ParseIntPipe) id: number) {
|
// remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.bankCodesService.remove(id);
|
// return this.bankCodesService.remove(id);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { PrismaService } from "../../../prisma/prisma.service";
|
|
||||||
import { computeHours, getWeekStart } from "src/common/utils/date-utils";
|
import { computeHours, getWeekStart } from "src/common/utils/date-utils";
|
||||||
|
import { PrismaService } from "../../../prisma/prisma.service";
|
||||||
|
|
||||||
|
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/*
|
||||||
|
le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier.
|
||||||
|
Un maximum de 08h00 est allouable pour le férier
|
||||||
|
Un maximum de 40hrs par semaine est retenue pour faire le calcul.
|
||||||
|
le bank-code à soumettre à Desjardins doit être le G104
|
||||||
|
*/
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HolidayService {
|
export class HolidayService {
|
||||||
|
|
@ -8,35 +17,63 @@ export class HolidayService {
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
//switch employeeId for email
|
//fetch employee_id by email
|
||||||
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> {
|
private async resolveEmployeeByEmail(email: string): Promise<number> {
|
||||||
//sets the end of the window to 1ms before the week with the holiday
|
const employee = await this.prisma.employees.findFirst({
|
||||||
const holiday_week_start = getWeekStart(holiday_date);
|
where: {
|
||||||
const window_end = new Date(holiday_week_start.getTime() - 1);
|
user: { email }
|
||||||
//sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday
|
},
|
||||||
const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 )
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if(!employee) throw new NotFoundException(`Employee with email : ${email} not found`);
|
||||||
|
return employee.id;
|
||||||
|
}
|
||||||
|
|
||||||
const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700'];
|
private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise<number> {
|
||||||
//fetches all shift of the employee in said window ( 4 previous completed weeks )
|
const employee_id = await this.resolveEmployeeByEmail(email);
|
||||||
|
return this.computeHoursPrevious4Weeks(employee_id, holiday_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> {
|
||||||
|
const holiday_week_start = getWeekStart(holiday_date);
|
||||||
|
const window_start = new Date(holiday_week_start.getTime() - 4 * WEEK_IN_MS);
|
||||||
|
const window_end = new Date(holiday_week_start.getTime() - 1);
|
||||||
|
|
||||||
|
const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G700'];
|
||||||
const shifts = await this.prisma.shifts.findMany({
|
const shifts = await this.prisma.shifts.findMany({
|
||||||
where: { timesheet: { employee_id: employee_id } ,
|
where: {
|
||||||
|
timesheet: { employee_id: employee_id },
|
||||||
date: { gte: window_start, lte: window_end },
|
date: { gte: window_start, lte: window_end },
|
||||||
bank_code: { bank_code: { in: valid_codes } },
|
bank_code: { bank_code: { in: valid_codes } },
|
||||||
},
|
},
|
||||||
select: { date: true, start_time: true, end_time: true },
|
select: { date: true, start_time: true, end_time: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0);
|
const hours_by_week = new Map<number, number>();
|
||||||
const daily_hours = total_hours / 20;
|
for(const shift of shifts) {
|
||||||
|
const hours = computeHours(shift.start_time, shift.end_time);
|
||||||
return daily_hours;
|
if(hours <= 0) continue;
|
||||||
|
const shift_week_start = getWeekStart(shift.date);
|
||||||
|
const key = shift_week_start.getTime();
|
||||||
|
hours_by_week.set(key, (hours_by_week.get(key) ?? 0) + hours);
|
||||||
}
|
}
|
||||||
|
|
||||||
//switch employeeId for email
|
let capped_total = 0;
|
||||||
async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise<number> {
|
for(let offset = 1; offset <= 4; offset++) {
|
||||||
const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date);
|
const week_start = new Date(holiday_week_start.getTime() - offset * WEEK_IN_MS);
|
||||||
const daily_rate = Math.min(hours, 8);
|
const key = week_start.getTime();
|
||||||
this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`);
|
const weekly_hours = hours_by_week.get(key) ?? 0;
|
||||||
|
capped_total += Math.min(weekly_hours, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
const average_daily_hours = capped_total / 20;
|
||||||
|
return average_daily_hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise<number> {
|
||||||
|
const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date);
|
||||||
|
const daily_rate = Math.min(average_daily_hours, 8);
|
||||||
|
this.logger.debug(`Holiday pay calculation: cappedHoursPerDay= ${average_daily_hours.toFixed(2)}, appliedDailyRate= ${daily_rate.toFixed(2)}`);
|
||||||
return daily_rate * modifier;
|
return daily_rate * modifier;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,55 +1,152 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
|
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OvertimeService {
|
export class OvertimeService {
|
||||||
|
|
||||||
private logger = new Logger(OvertimeService.name);
|
private logger = new Logger(OvertimeService.name);
|
||||||
private daily_max = 12; // maximum for regular hours per day
|
private daily_max = 8; // maximum for regular hours per day
|
||||||
private weekly_max = 80; //maximum for regular hours per week
|
private weekly_max = 40; //maximum for regular hours per week
|
||||||
|
private INCLUDED_TYPES = ['EMERGENCY', 'EVENING','OVERTIME','REGULAR'] as const; // included types for weekly overtime calculation
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
//calculate Daily overtime
|
//calculate daily overtime
|
||||||
getDailyOvertimeHours(start: Date, end: Date): number {
|
async getDailyOvertimeHoursForDay(employee_id: number, date: Date): Promise<number> {
|
||||||
const hours = computeHours(start, end, 5);
|
const shifts = await this.prisma.shifts.findMany({
|
||||||
const overtime = Math.max(0, hours - this.daily_max);
|
where: { date: date, timesheet: { employee_id: employee_id } },
|
||||||
this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`);
|
select: { start_time: true, end_time: true },
|
||||||
|
});
|
||||||
|
const total = shifts.map((shift)=>
|
||||||
|
computeHours(shift.start_time, shift.end_time, 5)).reduce((sum, hours)=> sum + hours, 0);
|
||||||
|
const overtime = Math.max(0, total - this.daily_max);
|
||||||
|
|
||||||
|
this.logger.debug(`[OVERTIME]-[DAILY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
|
||||||
return overtime;
|
return overtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
//calculate Weekly overtime
|
//calculate Weekly overtime
|
||||||
//switch employeeId for email
|
async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise<number> {
|
||||||
async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise<number> {
|
const week_start = getWeekStart(ref_date);
|
||||||
const week_start = getWeekStart(refDate);
|
|
||||||
const week_end = getWeekEnd(week_start);
|
const week_end = getWeekEnd(week_start);
|
||||||
|
|
||||||
//fetches all shifts containing hours
|
//fetches all shifts from INCLUDED_TYPES array
|
||||||
const shifts = await this.prisma.shifts.findMany({
|
const included_shifts = await this.prisma.shifts.findMany({
|
||||||
where: { timesheet: { employee_id: employeeId, shift: {
|
where: {
|
||||||
every: {date: { gte: week_start, lte: week_end } }
|
date: { gte:week_start, lte: week_end },
|
||||||
},
|
timesheet: { employee_id },
|
||||||
},
|
bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
|
||||||
},
|
},
|
||||||
select: { start_time: true, end_time: true },
|
select: { start_time: true, end_time: true },
|
||||||
|
orderBy: [{date: 'asc'}, {start_time:'asc'}],
|
||||||
});
|
});
|
||||||
|
|
||||||
//calculate total hours of those shifts minus weekly Max to find total overtime hours
|
//calculate total hours of those shifts minus weekly Max to find total overtime hours
|
||||||
const total = shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5))
|
const total = included_shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5))
|
||||||
.reduce((sum, hours)=> sum+hours, 0);
|
.reduce((sum, hours)=> sum+hours, 0);
|
||||||
const overtime = Math.max(0, total - this.weekly_max);
|
const overtime = Math.max(0, total - this.weekly_max);
|
||||||
|
|
||||||
this.logger.debug(`weekly total = ${total.toFixed(2)}h, weekly Overtime= ${overtime.toFixed(2)}h`);
|
this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
|
||||||
return overtime;
|
return overtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
//apply modifier to overtime hours
|
//transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift
|
||||||
calculateOvertimePay(overtime_hours: number, modifier: number): number {
|
async transformRegularHoursToWeeklyOvertime(
|
||||||
const pay = overtime_hours * modifier;
|
employee_id: number,
|
||||||
this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`);
|
ref_date: Date,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
|
): Promise<void> {
|
||||||
|
//ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected.
|
||||||
|
const db = tx ?? this.prisma;
|
||||||
|
|
||||||
return pay;
|
//calculate weekly overtime
|
||||||
|
const overtime_hours = await this.getWeeklyOvertimeHours(employee_id, ref_date);
|
||||||
|
if(overtime_hours <= 0) return;
|
||||||
|
|
||||||
|
const convert_to_minutes = Math.round(overtime_hours * 60);
|
||||||
|
|
||||||
|
const [regular, overtime] = await Promise.all([
|
||||||
|
db.bankCodes.findFirst({where: { type: 'REGULAR' }, select: { id: true } }),
|
||||||
|
db.bankCodes.findFirst({where: { type: 'OVERTIME'}, select: { id: true } }),
|
||||||
|
]);
|
||||||
|
if(!regular || !overtime) return;
|
||||||
|
|
||||||
|
const week_start = getWeekStart(ref_date);
|
||||||
|
const week_end = getWeekEnd(week_start);
|
||||||
|
|
||||||
|
//gets all regular shifts and order them by desc
|
||||||
|
const regular_shifts_desc = await db.shifts.findMany({
|
||||||
|
where: {
|
||||||
|
date: { gte:week_start, lte: week_end },
|
||||||
|
timesheet: { employee_id },
|
||||||
|
bank_code_id: regular.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
timesheet_id: true,
|
||||||
|
date: true,
|
||||||
|
start_time: true,
|
||||||
|
end_time: true,
|
||||||
|
is_remote: true,
|
||||||
|
comment: true,
|
||||||
|
},
|
||||||
|
orderBy: [{date: 'desc'}, {start_time:'desc'}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let remaining_minutes = convert_to_minutes;
|
||||||
|
|
||||||
|
for(const shift of regular_shifts_desc) {
|
||||||
|
if(remaining_minutes <= 0) break;
|
||||||
|
|
||||||
|
const start = shift.start_time;
|
||||||
|
const end = shift.end_time;
|
||||||
|
const duration_in_minutes = Math.max(0, Math.round((end.getTime() - start.getTime())/60000));
|
||||||
|
if(duration_in_minutes === 0) continue;
|
||||||
|
|
||||||
|
if(duration_in_minutes <= remaining_minutes) {
|
||||||
|
await db.shifts.update({
|
||||||
|
where: { id: shift.id },
|
||||||
|
data: { bank_code_id: overtime.id },
|
||||||
|
});
|
||||||
|
remaining_minutes -= duration_in_minutes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
//sets the start_time of the new overtime shift
|
||||||
|
const new_overtime_start = new Date(end.getTime() - remaining_minutes * 60000);
|
||||||
|
|
||||||
|
//shorten the regular shift
|
||||||
|
await db.shifts.update({
|
||||||
|
where: { id: shift.id },
|
||||||
|
data: { end_time: new_overtime_start },
|
||||||
|
});
|
||||||
|
|
||||||
|
//creates the new overtime shift to replace the shorten regular shift
|
||||||
|
await db.shifts.create({
|
||||||
|
data: {
|
||||||
|
timesheet_id: shift.timesheet_id,
|
||||||
|
date: shift.date,
|
||||||
|
start_time: new_overtime_start,
|
||||||
|
end_time: end,
|
||||||
|
is_remote: shift.is_remote,
|
||||||
|
comment: shift.comment,
|
||||||
|
bank_code_id: overtime.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
remaining_minutes = 0;
|
||||||
|
}
|
||||||
|
this.logger.debug(`[OVERTIME]-[WEEKLY]-[TRANSFORM] emp=${employee_id}
|
||||||
|
week: ${week_start.toISOString().slice(0,10)}..${week_end.toISOString().slice(0,10)}
|
||||||
|
converted= ${(convert_to_minutes-remaining_minutes)/60}h`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//apply modifier to overtime hours
|
||||||
|
// calculateOvertimePay(overtime_hours: number, modifier: number): number {
|
||||||
|
// const pay = overtime_hours * modifier;
|
||||||
|
// this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`);
|
||||||
|
|
||||||
|
// return pay;
|
||||||
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from "../../../prisma/prisma.service";
|
import { PrismaService } from "../../../prisma/prisma.service";
|
||||||
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SickLeaveService {
|
export class SickLeaveService {
|
||||||
|
|
@ -9,8 +9,17 @@ export class SickLeaveService {
|
||||||
private readonly logger = new Logger(SickLeaveService.name);
|
private readonly logger = new Logger(SickLeaveService.name);
|
||||||
|
|
||||||
//switch employeeId for email
|
//switch employeeId for email
|
||||||
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number):
|
async calculateSickLeavePay(
|
||||||
Promise<number> {
|
employee_id: number,
|
||||||
|
reference_date: Date,
|
||||||
|
days_requested: number,
|
||||||
|
hours_per_day: number,
|
||||||
|
modifier: number,
|
||||||
|
): Promise<number> {
|
||||||
|
if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
//sets the year to jan 1st to dec 31st
|
//sets the year to jan 1st to dec 31st
|
||||||
const period_start = getYearStart(reference_date);
|
const period_start = getYearStart(reference_date);
|
||||||
const period_end = reference_date;
|
const period_end = reference_date;
|
||||||
|
|
@ -19,18 +28,19 @@ export class SickLeaveService {
|
||||||
const shifts = await this.prisma.shifts.findMany({
|
const shifts = await this.prisma.shifts.findMany({
|
||||||
where: {
|
where: {
|
||||||
timesheet: { employee_id: employee_id },
|
timesheet: { employee_id: employee_id },
|
||||||
date: { gte: period_start, lte: period_end},
|
date: { gte: period_start, lte: period_end },
|
||||||
},
|
},
|
||||||
select: { date: true },
|
select: { date: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
//count the amount of worked days
|
//count the amount of worked days
|
||||||
const worked_dates = new Set(
|
const worked_dates = new Set(
|
||||||
shifts.map(shift => shift.date.toISOString().slice(0,10))
|
shifts.map((shift) => shift.date.toISOString().slice(0, 10)),
|
||||||
);
|
);
|
||||||
const days_worked = worked_dates.size;
|
const days_worked = worked_dates.size;
|
||||||
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()}
|
this.logger.debug(
|
||||||
-> ${period_end.toDateString()}`);
|
`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
//less than 30 worked days returns 0
|
//less than 30 worked days returns 0
|
||||||
if (days_worked < 30) {
|
if (days_worked < 30) {
|
||||||
|
|
@ -45,22 +55,31 @@ export class SickLeaveService {
|
||||||
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
|
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
|
||||||
|
|
||||||
//calculate each completed month, starting the 1st of the next month
|
//calculate each completed month, starting the 1st of the next month
|
||||||
const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1);
|
const first_bonus_date = new Date(
|
||||||
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
|
threshold_date.getFullYear(),
|
||||||
(period_end.getMonth() - first_bonus_date.getMonth()) + 1;
|
threshold_date.getMonth() + 1,
|
||||||
if(months < 0) months = 0;
|
1,
|
||||||
|
);
|
||||||
|
let months =
|
||||||
|
(period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
|
||||||
|
(period_end.getMonth() - first_bonus_date.getMonth()) +
|
||||||
|
1;
|
||||||
|
if (months < 0) months = 0;
|
||||||
acquired_days += months;
|
acquired_days += months;
|
||||||
|
|
||||||
//cap of 10 days
|
//cap of 10 days
|
||||||
if (acquired_days > 10) acquired_days = 10;
|
if (acquired_days > 10) acquired_days = 10;
|
||||||
|
|
||||||
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()}
|
this.logger.debug(
|
||||||
, bonusMonths = ${months}, acquired Days = ${acquired_days}`);
|
`Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`,
|
||||||
|
);
|
||||||
|
|
||||||
const payable_days = Math.min(acquired_days, days_requested);
|
const payable_days = Math.min(acquired_days, days_requested);
|
||||||
const raw_hours = payable_days * 8 * modifier;
|
const raw_hours = payable_days * hours_per_day * modifier;
|
||||||
const rounded = roundToQuarterHour(raw_hours)
|
const rounded = roundToQuarterHour(raw_hours);
|
||||||
this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`);
|
this.logger.debug(
|
||||||
|
`Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`,
|
||||||
|
);
|
||||||
return rounded;
|
return rounded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,15 +6,7 @@ export class VacationService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly logger = new Logger(VacationService.name);
|
private readonly logger = new Logger(VacationService.name);
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the ammount allowed for vacation days.
|
|
||||||
*
|
|
||||||
* @param employee_id employee ID
|
|
||||||
* @param startDate first day of vacation
|
|
||||||
* @param daysRequested number of days requested
|
|
||||||
* @param modifier Coefficient of hours(1)
|
|
||||||
* @returns amount of payable hours
|
|
||||||
*/
|
|
||||||
//switch employeeId for email
|
//switch employeeId for email
|
||||||
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
|
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
|
||||||
//fetch hiring date
|
//fetch hiring date
|
||||||
|
|
|
||||||
|
|
@ -14,51 +14,55 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
|
||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(private readonly customersService: CustomersService) {}
|
constructor(private readonly customersService: CustomersService) {}
|
||||||
|
|
||||||
@Post()
|
//_____________________________________________________________________________________________
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR)
|
// Deprecated or unused methods
|
||||||
@ApiOperation({ summary: 'Create customer' })
|
//_____________________________________________________________________________________________
|
||||||
@ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto })
|
|
||||||
@ApiResponse({ status: 400, description: 'Invalid task or invalid data' })
|
|
||||||
create(@Body() dto: CreateCustomerDto): Promise<Customers> {
|
|
||||||
return this.customersService.create(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
// @Post()
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR)
|
||||||
@ApiOperation({ summary: 'Find all customers' })
|
// @ApiOperation({ summary: 'Create customer' })
|
||||||
@ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true })
|
// @ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto })
|
||||||
@ApiResponse({ status: 400, description: 'List of customers not found' })
|
// @ApiResponse({ status: 400, description: 'Invalid task or invalid data' })
|
||||||
findAll(): Promise<Customers[]> {
|
// create(@Body() dto: CreateCustomerDto): Promise<Customers> {
|
||||||
return this.customersService.findAll();
|
// return this.customersService.create(dto);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Get(':id')
|
// @Get()
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
@ApiOperation({ summary: 'Find customer' })
|
// @ApiOperation({ summary: 'Find all customers' })
|
||||||
@ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto })
|
// @ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true })
|
||||||
@ApiResponse({ status: 400, description: 'Customer not found' })
|
// @ApiResponse({ status: 400, description: 'List of customers not found' })
|
||||||
findOne(@Param('id', ParseIntPipe) id: number): Promise<Customers> {
|
// findAll(): Promise<Customers[]> {
|
||||||
return this.customersService.findOne(id);
|
// return this.customersService.findAll();
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Patch(':id')
|
// @Get(':id')
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR)
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
@ApiOperation({ summary: 'Update customer' })
|
// @ApiOperation({ summary: 'Find customer' })
|
||||||
@ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto })
|
// @ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto })
|
||||||
@ApiResponse({ status: 400, description: 'Customer not found' })
|
// @ApiResponse({ status: 400, description: 'Customer not found' })
|
||||||
update(
|
// findOne(@Param('id', ParseIntPipe) id: number): Promise<Customers> {
|
||||||
@Param('id', ParseIntPipe) id: number,
|
// return this.customersService.findOne(id);
|
||||||
@Body() dto: UpdateCustomerDto,
|
// }
|
||||||
): Promise<Customers> {
|
|
||||||
return this.customersService.update(id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
// @Patch(':id')
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR)
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR)
|
||||||
@ApiOperation({ summary: 'Delete customer' })
|
// @ApiOperation({ summary: 'Update customer' })
|
||||||
@ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto })
|
// @ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto })
|
||||||
@ApiResponse({ status: 400, description: 'Customer not found' })
|
// @ApiResponse({ status: 400, description: 'Customer not found' })
|
||||||
remove(@Param('id', ParseIntPipe) id: number): Promise<Customers>{
|
// update(
|
||||||
return this.customersService.remove(id);
|
// @Param('id', ParseIntPipe) id: number,
|
||||||
}
|
// @Body() dto: UpdateCustomerDto,
|
||||||
|
// ): Promise<Customers> {
|
||||||
|
// return this.customersService.update(id, dto);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Delete(':id')
|
||||||
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR)
|
||||||
|
// @ApiOperation({ summary: 'Delete customer' })
|
||||||
|
// @ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto })
|
||||||
|
// @ApiResponse({ status: 400, description: 'Customer not found' })
|
||||||
|
// remove(@Param('id', ParseIntPipe) id: number): Promise<Customers>{
|
||||||
|
// return this.customersService.remove(id);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,8 @@ export class CreateCustomerDto {
|
||||||
example: '8436637464',
|
example: '8436637464',
|
||||||
description: 'Customer`s phone number',
|
description: 'Customer`s phone number',
|
||||||
})
|
})
|
||||||
@Type(() => Number)
|
@IsString()
|
||||||
@IsInt()
|
phone_number: string;
|
||||||
@IsPositive()
|
|
||||||
phone_number: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ',
|
example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ',
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,93 @@
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { CreateCustomerDto } from '../dtos/create-customer.dto';
|
|
||||||
import { Customers, Users } from '@prisma/client';
|
|
||||||
import { UpdateCustomerDto } from '../dtos/update-customer.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersService {
|
export class CustomersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async create(dto: CreateCustomerDto): Promise<Customers> {
|
//_____________________________________________________________________________________________
|
||||||
const {
|
// Deprecated or unused methods
|
||||||
first_name,
|
//_____________________________________________________________________________________________
|
||||||
last_name,
|
|
||||||
email,
|
|
||||||
phone_number,
|
|
||||||
residence,
|
|
||||||
invoice_id,
|
|
||||||
} = dto;
|
|
||||||
|
|
||||||
return this.prisma.$transaction(async (transaction) => {
|
// constructor(private readonly prisma: PrismaService) {}
|
||||||
const user: Users = await transaction.users.create({
|
|
||||||
data: {
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
email,
|
|
||||||
phone_number,
|
|
||||||
residence,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return transaction.customers.create({
|
|
||||||
data: {
|
|
||||||
user_id: user.id,
|
|
||||||
invoice_id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
findAll(): Promise<Customers[]> {
|
// async create(dto: CreateCustomerDto): Promise<Customers> {
|
||||||
return this.prisma.customers.findMany({
|
// const {
|
||||||
include: { user: true },
|
// first_name,
|
||||||
})
|
// last_name,
|
||||||
}
|
// email,
|
||||||
|
// phone_number,
|
||||||
|
// residence,
|
||||||
|
// invoice_id,
|
||||||
|
// } = dto;
|
||||||
|
|
||||||
async findOne(id:number): Promise<Customers> {
|
// return this.prisma.$transaction(async (transaction) => {
|
||||||
const customer = await this.prisma.customers.findUnique({
|
// const user: Users = await transaction.users.create({
|
||||||
where: { id },
|
// data: {
|
||||||
include: { user: true },
|
// first_name,
|
||||||
});
|
// last_name,
|
||||||
if(!customer) throw new NotFoundException(`Customer #${id} not found`);
|
// email,
|
||||||
return customer;
|
// phone_number,
|
||||||
}
|
// residence,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// return transaction.customers.create({
|
||||||
|
// data: {
|
||||||
|
// user_id: user.id,
|
||||||
|
// invoice_id,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
async update(id: number,dto: UpdateCustomerDto): Promise<Customers> {
|
// findAll(): Promise<Customers[]> {
|
||||||
const customer = await this.findOne(id);
|
// return this.prisma.customers.findMany({
|
||||||
|
// include: { user: true },
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
const {
|
// async findOne(id:number): Promise<Customers> {
|
||||||
first_name,
|
// const customer = await this.prisma.customers.findUnique({
|
||||||
last_name,
|
// where: { id },
|
||||||
email,
|
// include: { user: true },
|
||||||
phone_number,
|
// });
|
||||||
residence,
|
// if(!customer) throw new NotFoundException(`Customer #${id} not found`);
|
||||||
invoice_id,
|
// return customer;
|
||||||
} = dto;
|
// }
|
||||||
|
|
||||||
return this.prisma.$transaction(async (transaction) => {
|
// async update(id: number,dto: UpdateCustomerDto): Promise<Customers> {
|
||||||
await transaction.users.update({
|
// const customer = await this.findOne(id);
|
||||||
where: { id: customer.user_id },
|
|
||||||
data: {
|
|
||||||
...(first_name !== undefined && { first_name }),
|
|
||||||
...(last_name !== undefined && { last_name }),
|
|
||||||
...(email !== undefined && { email }),
|
|
||||||
...(phone_number !== undefined && { phone_number }),
|
|
||||||
...(residence !== undefined && { residence }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return transaction.customers.update({
|
// const {
|
||||||
where: { id },
|
// first_name,
|
||||||
data: {
|
// last_name,
|
||||||
...(invoice_id !== undefined && { invoice_id }),
|
// email,
|
||||||
},
|
// phone_number,
|
||||||
});
|
// residence,
|
||||||
});
|
// invoice_id,
|
||||||
}
|
// } = dto;
|
||||||
|
|
||||||
async remove(id: number): Promise<Customers> {
|
// return this.prisma.$transaction(async (transaction) => {
|
||||||
await this.findOne(id);
|
// await transaction.users.update({
|
||||||
return this.prisma.customers.delete({ where: { id }});
|
// where: { id: customer.user_id },
|
||||||
}
|
// data: {
|
||||||
|
// ...(first_name !== undefined && { first_name }),
|
||||||
|
// ...(last_name !== undefined && { last_name }),
|
||||||
|
// ...(email !== undefined && { email }),
|
||||||
|
// ...(phone_number !== undefined && { phone_number }),
|
||||||
|
// ...(residence !== undefined && { residence }),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return transaction.customers.update({
|
||||||
|
// where: { id },
|
||||||
|
// data: {
|
||||||
|
// ...(invoice_id !== undefined && { invoice_id }),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async remove(id: number): Promise<Customers> {
|
||||||
|
// await this.findOne(id);
|
||||||
|
// return this.prisma.customers.delete({ where: { id }});
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,21 @@
|
||||||
import { Body,Controller,Delete,Get,NotFoundException,Param,ParseIntPipe,Patch,Post,UseGuards } from '@nestjs/common';
|
import { Body,Controller,Get,NotFoundException,Param,Patch } from '@nestjs/common';
|
||||||
import { Employees, Roles as RoleEnum } from '@prisma/client';
|
|
||||||
import { EmployeesService } from '../services/employees.service';
|
import { EmployeesService } from '../services/employees.service';
|
||||||
import { CreateEmployeeDto } from '../dtos/create-employee.dto';
|
import { CreateEmployeeDto } from '../dtos/create-employee.dto';
|
||||||
import { UpdateEmployeeDto } from '../dtos/update-employee.dto';
|
import { UpdateEmployeeDto } from '../dtos/update-employee.dto';
|
||||||
import { RolesAllowed } from '../../../common/decorators/roles.decorators';
|
import { RolesAllowed } from '../../../common/decorators/roles.decorators';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { EmployeeListItemDto } from '../dtos/list-employee.dto';
|
import { EmployeeListItemDto } from '../dtos/list-employee.dto';
|
||||||
import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
|
import { EmployeesArchivalService } from '../services/employees-archival.service';
|
||||||
|
|
||||||
@ApiTags('Employees')
|
@ApiTags('Employees')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
// @UseGuards()
|
// @UseGuards()
|
||||||
@Controller('employees')
|
@Controller('employees')
|
||||||
export class EmployeesController {
|
export class EmployeesController {
|
||||||
constructor(private readonly employeesService: EmployeesService) {}
|
constructor(
|
||||||
|
private readonly employeesService: EmployeesService,
|
||||||
@Post()
|
private readonly archiveService: EmployeesArchivalService,
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
) {}
|
||||||
@ApiOperation({summary: 'Create employee' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto })
|
|
||||||
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
|
||||||
create(@Body() dto: CreateEmployeeDto): Promise<Employees> {
|
|
||||||
return this.employeesService.create(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
|
|
||||||
@ApiOperation({summary: 'Find all employees' })
|
|
||||||
@ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true })
|
|
||||||
@ApiResponse({ status: 400, description: 'List of employees not found' })
|
|
||||||
findAll(): Promise<Employees[]> {
|
|
||||||
return this.employeesService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('employee-list')
|
@Get('employee-list')
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
|
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
|
||||||
|
|
@ -42,34 +26,6 @@ export class EmployeesController {
|
||||||
return this.employeesService.findListEmployees();
|
return this.employeesService.findListEmployees();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':email')
|
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING )
|
|
||||||
@ApiOperation({summary: 'Find employee' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto })
|
|
||||||
@ApiResponse({ status: 400, description: 'Employee not found' })
|
|
||||||
findOne(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
|
|
||||||
return this.employeesService.findOne(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('profile/:email')
|
|
||||||
@ApiOperation({summary: 'Find employee profile' })
|
|
||||||
@ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
|
|
||||||
@ApiResponse({ status: 400, description: 'Employee profile not found' })
|
|
||||||
findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
|
|
||||||
return this.employeesService.findOneProfile(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':email')
|
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )
|
|
||||||
@ApiOperation({summary: 'Delete employee' })
|
|
||||||
@ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' })
|
|
||||||
@ApiResponse({ status: 204, description: 'Employee deleted' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Employee not found' })
|
|
||||||
remove(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
|
|
||||||
return this.employeesService.remove(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':email')
|
@Patch(':email')
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
|
|
@ -82,10 +38,61 @@ export class EmployeesController {
|
||||||
// if last_work_day is set => archive the employee
|
// if last_work_day is set => archive the employee
|
||||||
// else if employee is archived and first_work_day or last_work_day = null => restore
|
// else if employee is archived and first_work_day or last_work_day = null => restore
|
||||||
//otherwise => standard update
|
//otherwise => standard update
|
||||||
const result = await this.employeesService.patchEmployee(email, dto);
|
const result = await this.archiveService.patchEmployee(email, dto);
|
||||||
if(!result) {
|
if(!result) {
|
||||||
throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`)
|
throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`)
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
// Deprecated or unused methods
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
|
||||||
|
// @Post()
|
||||||
|
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
// @ApiOperation({summary: 'Create employee' })
|
||||||
|
// @ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto })
|
||||||
|
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||||
|
// create(@Body() dto: CreateEmployeeDto): Promise<Employees> {
|
||||||
|
// return this.employeesService.create(dto);
|
||||||
|
// }
|
||||||
|
// @Get()
|
||||||
|
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
|
||||||
|
// @ApiOperation({summary: 'Find all employees' })
|
||||||
|
// @ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true })
|
||||||
|
// @ApiResponse({ status: 400, description: 'List of employees not found' })
|
||||||
|
// findAll(): Promise<Employees[]> {
|
||||||
|
// return this.employeesService.findAll();
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// @Get(':email')
|
||||||
|
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING )
|
||||||
|
// @ApiOperation({summary: 'Find employee' })
|
||||||
|
// @ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto })
|
||||||
|
// @ApiResponse({ status: 400, description: 'Employee not found' })
|
||||||
|
// findOne(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
|
||||||
|
// return this.employeesService.findOne(email);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Get('profile/:email')
|
||||||
|
// @ApiOperation({summary: 'Find employee profile' })
|
||||||
|
// @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
|
||||||
|
// @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
|
||||||
|
// @ApiResponse({ status: 400, description: 'Employee profile not found' })
|
||||||
|
// findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
|
||||||
|
// return this.employeesService.findOneProfile(email);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Delete(':email')
|
||||||
|
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )
|
||||||
|
// @ApiOperation({summary: 'Delete employee' })
|
||||||
|
// @ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' })
|
||||||
|
// @ApiResponse({ status: 204, description: 'Employee deleted' })
|
||||||
|
// @ApiResponse({ status: 404, description: 'Employee not found' })
|
||||||
|
// remove(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
|
||||||
|
// return this.employeesService.remove(email);
|
||||||
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,8 @@ export class CreateEmployeeDto {
|
||||||
example: '82538437464',
|
example: '82538437464',
|
||||||
description: 'Employee`s phone number',
|
description: 'Employee`s phone number',
|
||||||
})
|
})
|
||||||
@Type(() => Number)
|
@IsString()
|
||||||
@IsInt()
|
phone_number: string;
|
||||||
@IsPositive()
|
|
||||||
phone_number: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth',
|
example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth',
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export class EmployeeProfileItemDto {
|
||||||
company_name: number | null;
|
company_name: number | null;
|
||||||
job_title: string | null;
|
job_title: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone_number: number;
|
phone_number: string;
|
||||||
first_work_day: string;
|
first_work_day: string;
|
||||||
last_work_day?: string | null;
|
last_work_day?: string | null;
|
||||||
residence: string | null;
|
residence: string | null;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,6 @@ export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
supervisor_id?: number;
|
supervisor_id?: number;
|
||||||
|
|
||||||
@Max(2147483647)
|
@IsOptional()
|
||||||
phone_number: number;
|
phone_number: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { EmployeesController } from './controllers/employees.controller';
|
import { EmployeesController } from './controllers/employees.controller';
|
||||||
import { EmployeesService } from './services/employees.service';
|
import { EmployeesService } from './services/employees.service';
|
||||||
|
import { EmployeesArchivalService } from './services/employees-archival.service';
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EmployeesController],
|
controllers: [EmployeesController, SharedModule],
|
||||||
providers: [EmployeesService],
|
providers: [EmployeesService, EmployeesArchivalService],
|
||||||
exports: [EmployeesService],
|
exports: [EmployeesService, EmployeesArchivalService],
|
||||||
})
|
})
|
||||||
export class EmployeesModule {}
|
export class EmployeesModule {}
|
||||||
|
|
|
||||||
173
src/modules/employees/services/employees-archival.service.ts
Normal file
173
src/modules/employees/services/employees-archival.service.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { Employees, EmployeesArchive, Users } from "@prisma/client";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { UpdateEmployeeDto } from "../dtos/update-employee.dto";
|
||||||
|
import { toDateOrUndefined, toDateOrNull } from "../utils/employee.utils";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmployeesArchivalService {
|
||||||
|
constructor(private readonly prisma: PrismaService) { }
|
||||||
|
|
||||||
|
async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees | EmployeesArchive | null> {
|
||||||
|
// 1) Tenter sur employés actifs
|
||||||
|
const active = await this.prisma.employees.findFirst({
|
||||||
|
where: { user: { email } },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
// Archivage : si on reçoit un last_work_day défini et que l'employé n’est pas déjà terminé
|
||||||
|
if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) {
|
||||||
|
return this.archiveOnTermination(active, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon, update standard (split Users/Employees)
|
||||||
|
const {
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email: new_email,
|
||||||
|
phone_number,
|
||||||
|
residence,
|
||||||
|
external_payroll_id,
|
||||||
|
company_code,
|
||||||
|
job_title,
|
||||||
|
first_work_day,
|
||||||
|
last_work_day,
|
||||||
|
supervisor_id,
|
||||||
|
is_supervisor,
|
||||||
|
} = dto as any;
|
||||||
|
|
||||||
|
const first_work_d = toDateOrUndefined(first_work_day);
|
||||||
|
const last_work_d = Object.prototype.hasOwnProperty('last_work_day')
|
||||||
|
? toDateOrNull(last_work_day ?? null)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (transaction) => {
|
||||||
|
if (
|
||||||
|
first_name !== undefined ||
|
||||||
|
last_name !== undefined ||
|
||||||
|
new_email !== undefined ||
|
||||||
|
phone_number !== undefined ||
|
||||||
|
residence !== undefined
|
||||||
|
) {
|
||||||
|
await transaction.users.update({
|
||||||
|
where: { id: active.user_id },
|
||||||
|
data: {
|
||||||
|
...(first_name !== undefined ? { first_name } : {}),
|
||||||
|
...(last_name !== undefined ? { last_name } : {}),
|
||||||
|
...(email !== undefined ? { email: new_email } : {}),
|
||||||
|
...(phone_number !== undefined ? { phone_number } : {}),
|
||||||
|
...(residence !== undefined ? { residence } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await transaction.employees.update({
|
||||||
|
where: { id: active.id },
|
||||||
|
data: {
|
||||||
|
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
|
||||||
|
...(company_code !== undefined ? { company_code } : {}),
|
||||||
|
...(job_title !== undefined ? { job_title } : {}),
|
||||||
|
...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}),
|
||||||
|
...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}),
|
||||||
|
...(is_supervisor !== undefined ? { is_supervisor } : {}),
|
||||||
|
...(supervisor_id !== undefined ? { supervisor_id } : {}),
|
||||||
|
},
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.prisma.employees.findFirst({ where: { user: { email } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.users.findUnique({ where: { email } });
|
||||||
|
if (!user) return null;
|
||||||
|
// 2) Pas trouvé en actifs → regarder en archive (pour restauration)
|
||||||
|
const archived = await this.prisma.employeesArchive.findFirst({
|
||||||
|
where: { user_id: user.id },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (archived) {
|
||||||
|
// Condition de restauration : last_work_day === null ou first_work_day fourni
|
||||||
|
const restore = dto.last_work_day === null || dto.first_work_day != null;
|
||||||
|
if (restore) {
|
||||||
|
return this.restoreEmployee(archived, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) Ni actif, ni archivé → 404 dans le controller
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
//transfers the employee to archive and then delete from employees table
|
||||||
|
private async archiveOnTermination(active: Employees & { user: Users }, dto: UpdateEmployeeDto): Promise<EmployeesArchive> {
|
||||||
|
const last_work_d = toDateOrNull(dto.last_work_day!);
|
||||||
|
if (!last_work_d) throw new Error('invalide last_work_day for archive');
|
||||||
|
return this.prisma.$transaction(async transaction => {
|
||||||
|
//detach crew from supervisor if employee is a supervisor
|
||||||
|
await transaction.employees.updateMany({
|
||||||
|
where: { supervisor_id: active.id },
|
||||||
|
data: { supervisor_id: null },
|
||||||
|
})
|
||||||
|
const archived = await transaction.employeesArchive.create({
|
||||||
|
data: {
|
||||||
|
employee_id: active.id,
|
||||||
|
user_id: active.user_id,
|
||||||
|
first_name: active.user.first_name,
|
||||||
|
last_name: active.user.last_name,
|
||||||
|
company_code: active.company_code,
|
||||||
|
job_title: active.job_title,
|
||||||
|
first_work_day: active.first_work_day,
|
||||||
|
last_work_day: last_work_d,
|
||||||
|
supervisor_id: active.supervisor_id ?? null,
|
||||||
|
is_supervisor: active.is_supervisor,
|
||||||
|
external_payroll_id: active.external_payroll_id,
|
||||||
|
},
|
||||||
|
include: { user: true }
|
||||||
|
});
|
||||||
|
//delete from employees table
|
||||||
|
await transaction.employees.delete({ where: { id: active.id } });
|
||||||
|
//return archived employee
|
||||||
|
return archived
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//transfers the employee from archive to the employees table
|
||||||
|
private async restoreEmployee(archived: EmployeesArchive & { user: Users }, dto: UpdateEmployeeDto): Promise<Employees> {
|
||||||
|
// const first_work_d = toDateOrUndefined(dto.first_work_day);
|
||||||
|
return this.prisma.$transaction(async transaction => {
|
||||||
|
//restores the archived employee into the employees table
|
||||||
|
const restored = await transaction.employees.create({
|
||||||
|
data: {
|
||||||
|
user_id: archived.user_id,
|
||||||
|
company_code: archived.company_code,
|
||||||
|
job_title: archived.job_title,
|
||||||
|
first_work_day: archived.first_work_day,
|
||||||
|
last_work_day: null,
|
||||||
|
is_supervisor: archived.is_supervisor ?? false,
|
||||||
|
external_payroll_id: archived.external_payroll_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//deleting archived entry by id
|
||||||
|
await transaction.employeesArchive.delete({ where: { id: archived.id } });
|
||||||
|
|
||||||
|
//return restored employee
|
||||||
|
return restored;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//fetches all archived employees
|
||||||
|
async findAllArchived(): Promise<EmployeesArchive[]> {
|
||||||
|
return this.prisma.employeesArchive.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
//fetches an archived employee
|
||||||
|
async findOneArchived(id: number): Promise<EmployeesArchive> {
|
||||||
|
return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,69 +1,11 @@
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { CreateEmployeeDto } from '../dtos/create-employee.dto';
|
|
||||||
import { UpdateEmployeeDto } from '../dtos/update-employee.dto';
|
|
||||||
import { Employees, EmployeesArchive, Users } from '@prisma/client';
|
|
||||||
import { EmployeeListItemDto } from '../dtos/list-employee.dto';
|
import { EmployeeListItemDto } from '../dtos/list-employee.dto';
|
||||||
import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
|
import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
|
||||||
|
|
||||||
function toDateOrNull(v?: string | null): Date | null {
|
|
||||||
if (!v) return null;
|
|
||||||
const day = new Date(v);
|
|
||||||
return isNaN(day.getTime()) ? null : day;
|
|
||||||
}
|
|
||||||
function toDateOrUndefined(v?: string | null): Date | undefined {
|
|
||||||
const day = toDateOrNull(v ?? undefined);
|
|
||||||
return day === null ? undefined : day;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmployeesService {
|
export class EmployeesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) { }
|
||||||
|
|
||||||
async create(dto: CreateEmployeeDto): Promise<Employees> {
|
|
||||||
const {
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
email,
|
|
||||||
phone_number,
|
|
||||||
residence,
|
|
||||||
external_payroll_id,
|
|
||||||
company_code,
|
|
||||||
job_title,
|
|
||||||
first_work_day,
|
|
||||||
last_work_day,
|
|
||||||
is_supervisor,
|
|
||||||
} = dto;
|
|
||||||
|
|
||||||
return this.prisma.$transaction(async (transaction) => {
|
|
||||||
const user: Users = await transaction.users.create({
|
|
||||||
data: {
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
email,
|
|
||||||
phone_number,
|
|
||||||
residence,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return transaction.employees.create({
|
|
||||||
data: {
|
|
||||||
user_id: user.id,
|
|
||||||
external_payroll_id,
|
|
||||||
company_code,
|
|
||||||
job_title,
|
|
||||||
first_work_day,
|
|
||||||
last_work_day,
|
|
||||||
is_supervisor,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
findAll(): Promise<Employees[]> {
|
|
||||||
return this.prisma.employees.findMany({
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
findListEmployees(): Promise<EmployeeListItemDto[]> {
|
findListEmployees(): Promise<EmployeeListItemDto[]> {
|
||||||
return this.prisma.employees.findMany({
|
return this.prisma.employees.findMany({
|
||||||
|
|
@ -91,29 +33,16 @@ export class EmployeesService {
|
||||||
}).then(rows => rows.map(r => ({
|
}).then(rows => rows.map(r => ({
|
||||||
first_name: r.user.first_name,
|
first_name: r.user.first_name,
|
||||||
last_name: r.user.last_name,
|
last_name: r.user.last_name,
|
||||||
employee_full_name: `${r.user.first_name} ${r.user.last_name}`,
|
|
||||||
email: r.user.email,
|
email: r.user.email,
|
||||||
supervisor_full_name: r.supervisor ? `${r.supervisor.user.first_name} ${r.supervisor.user.last_name}` : null,
|
|
||||||
company_name: r.company_code,
|
company_name: r.company_code,
|
||||||
job_title: r.job_title,
|
job_title: r.job_title,
|
||||||
|
employee_full_name: `${r.user.first_name} ${r.user.last_name}`,
|
||||||
|
supervisor_full_name: r.supervisor ? `${r.supervisor.user.first_name} ${r.supervisor.user.last_name}` : null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(email: string): Promise<Employees> {
|
async findOneProfile(email: string): Promise<EmployeeProfileItemDto> {
|
||||||
const emp = await this.prisma.employees.findFirst({
|
|
||||||
where: { user: { email } },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
//add search for archived employees
|
|
||||||
if (!emp) {
|
|
||||||
throw new NotFoundException(`Employee with email: ${email} not found`);
|
|
||||||
}
|
|
||||||
return emp;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOneProfile(email:string): Promise<EmployeeProfileItemDto> {
|
|
||||||
const emp = await this.prisma.employees.findFirst({
|
const emp = await this.prisma.employees.findFirst({
|
||||||
where: { user: { email } },
|
where: { user: { email } },
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -147,255 +76,155 @@ export class EmployeesService {
|
||||||
return {
|
return {
|
||||||
first_name: emp.user.first_name,
|
first_name: emp.user.first_name,
|
||||||
last_name: emp.user.last_name,
|
last_name: emp.user.last_name,
|
||||||
employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`,
|
|
||||||
email: emp.user.email,
|
email: emp.user.email,
|
||||||
residence: emp.user.residence,
|
residence: emp.user.residence,
|
||||||
phone_number: emp.user.phone_number,
|
phone_number: emp.user.phone_number,
|
||||||
supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null,
|
|
||||||
company_name: emp.company_code,
|
company_name: emp.company_code,
|
||||||
job_title: emp.job_title,
|
job_title: emp.job_title,
|
||||||
first_work_day: emp.first_work_day.toISOString().slice(0,10),
|
employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`,
|
||||||
last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0,10) : null,
|
first_work_day: emp.first_work_day.toISOString().slice(0, 10),
|
||||||
|
last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0, 10) : null,
|
||||||
|
supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
//_____________________________________________________________________________________________
|
||||||
email: string,
|
// Deprecated or unused methods
|
||||||
dto: UpdateEmployeeDto,
|
//_____________________________________________________________________________________________
|
||||||
): Promise<Employees> {
|
|
||||||
const emp = await this.findOne(email);
|
|
||||||
|
|
||||||
const {
|
// async create(dto: CreateEmployeeDto): Promise<Employees> {
|
||||||
first_name,
|
// const {
|
||||||
last_name,
|
// first_name,
|
||||||
phone_number,
|
// last_name,
|
||||||
residence,
|
// email,
|
||||||
external_payroll_id,
|
// phone_number,
|
||||||
company_code,
|
// residence,
|
||||||
job_title,
|
// external_payroll_id,
|
||||||
first_work_day,
|
// company_code,
|
||||||
last_work_day,
|
// job_title,
|
||||||
is_supervisor,
|
// first_work_day,
|
||||||
email: new_email,
|
// last_work_day,
|
||||||
} = dto;
|
// is_supervisor,
|
||||||
|
// } = dto;
|
||||||
|
|
||||||
return this.prisma.$transaction(async (transaction) => {
|
// return this.prisma.$transaction(async (transaction) => {
|
||||||
if(
|
// const user: Users = await transaction.users.create({
|
||||||
first_name !== undefined ||
|
// data: {
|
||||||
last_name !== undefined ||
|
// first_name,
|
||||||
new_email !== undefined ||
|
// last_name,
|
||||||
phone_number !== undefined ||
|
// email,
|
||||||
residence !== undefined
|
// phone_number,
|
||||||
){
|
// residence,
|
||||||
await transaction.users.update({
|
// },
|
||||||
where: { id: emp.user_id },
|
// });
|
||||||
data: {
|
// return transaction.employees.create({
|
||||||
...(first_name !== undefined && { first_name }),
|
// data: {
|
||||||
...(last_name !== undefined && { last_name }),
|
// user_id: user.id,
|
||||||
...(email !== undefined && { email }),
|
// external_payroll_id,
|
||||||
...(phone_number !== undefined && { phone_number }),
|
// company_code,
|
||||||
...(residence !== undefined && { residence }),
|
// job_title,
|
||||||
},
|
// first_work_day,
|
||||||
});
|
// last_work_day,
|
||||||
}
|
// is_supervisor,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
const updated = await transaction.employees.update({
|
// findAll(): Promise<Employees[]> {
|
||||||
where: { id: emp.id },
|
// return this.prisma.employees.findMany({
|
||||||
data: {
|
// include: { user: true },
|
||||||
...(external_payroll_id !== undefined && { external_payroll_id }),
|
// });
|
||||||
...(company_code !== undefined && { company_code }),
|
// }
|
||||||
...(first_work_day !== undefined && { first_work_day }),
|
|
||||||
...(last_work_day !== undefined && { last_work_day }),
|
// async findOne(email: string): Promise<Employees> {
|
||||||
...(job_title !== undefined && { job_title }),
|
// const emp = await this.prisma.employees.findFirst({
|
||||||
...(is_supervisor !== undefined && { is_supervisor }),
|
// where: { user: { email } },
|
||||||
},
|
// include: { user: true },
|
||||||
});
|
// });
|
||||||
return updated;
|
|
||||||
});
|
// //add search for archived employees
|
||||||
}
|
// if (!emp) {
|
||||||
|
// throw new NotFoundException(`Employee with email: ${email} not found`);
|
||||||
|
// }
|
||||||
|
// return emp;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async update(
|
||||||
|
// email: string,
|
||||||
|
// dto: UpdateEmployeeDto,
|
||||||
|
// ): Promise<Employees> {
|
||||||
|
// const emp = await this.findOne(email);
|
||||||
|
|
||||||
|
// const {
|
||||||
|
// first_name,
|
||||||
|
// last_name,
|
||||||
|
// phone_number,
|
||||||
|
// residence,
|
||||||
|
// external_payroll_id,
|
||||||
|
// company_code,
|
||||||
|
// job_title,
|
||||||
|
// first_work_day,
|
||||||
|
// last_work_day,
|
||||||
|
// is_supervisor,
|
||||||
|
// email: new_email,
|
||||||
|
// } = dto;
|
||||||
|
|
||||||
|
// return this.prisma.$transaction(async (transaction) => {
|
||||||
|
// if(
|
||||||
|
// first_name !== undefined ||
|
||||||
|
// last_name !== undefined ||
|
||||||
|
// new_email !== undefined ||
|
||||||
|
// phone_number !== undefined ||
|
||||||
|
// residence !== undefined
|
||||||
|
// ){
|
||||||
|
// await transaction.users.update({
|
||||||
|
// where: { id: emp.user_id },
|
||||||
|
// data: {
|
||||||
|
// ...(first_name !== undefined && { first_name }),
|
||||||
|
// ...(last_name !== undefined && { last_name }),
|
||||||
|
// ...(email !== undefined && { email }),
|
||||||
|
// ...(phone_number !== undefined && { phone_number }),
|
||||||
|
// ...(residence !== undefined && { residence }),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const updated = await transaction.employees.update({
|
||||||
|
// where: { id: emp.id },
|
||||||
|
// data: {
|
||||||
|
// ...(external_payroll_id !== undefined && { external_payroll_id }),
|
||||||
|
// ...(company_code !== undefined && { company_code }),
|
||||||
|
// ...(first_work_day !== undefined && { first_work_day }),
|
||||||
|
// ...(last_work_day !== undefined && { last_work_day }),
|
||||||
|
// ...(job_title !== undefined && { job_title }),
|
||||||
|
// ...(is_supervisor !== undefined && { is_supervisor }),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// return updated;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
async remove(email: string): Promise<Employees> {
|
// async remove(email: string): Promise<Employees> {
|
||||||
|
|
||||||
const emp = await this.findOne(email);
|
// const emp = await this.findOne(email);
|
||||||
|
|
||||||
return this.prisma.$transaction(async (transaction) => {
|
// return this.prisma.$transaction(async (transaction) => {
|
||||||
await transaction.employees.updateMany({
|
// await transaction.employees.updateMany({
|
||||||
where: { supervisor_id: emp.id },
|
// where: { supervisor_id: emp.id },
|
||||||
data: { supervisor_id: null },
|
// data: { supervisor_id: null },
|
||||||
});
|
// });
|
||||||
const deleted_employee = await transaction.employees.delete({
|
// const deleted_employee = await transaction.employees.delete({
|
||||||
where: {id: emp.id },
|
// where: {id: emp.id },
|
||||||
});
|
// });
|
||||||
await transaction.users.delete({
|
// await transaction.users.delete({
|
||||||
where: { id: emp.user_id },
|
// where: { id: emp.user_id },
|
||||||
});
|
// });
|
||||||
return deleted_employee;
|
// return deleted_employee;
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
//archivation functions ******************************************************
|
|
||||||
|
|
||||||
async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees | EmployeesArchive | null> {
|
|
||||||
// 1) Tenter sur employés actifs
|
|
||||||
const active = await this.prisma.employees.findFirst({
|
|
||||||
where: { user: { email } },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (active) {
|
|
||||||
// Archivage : si on reçoit un last_work_day défini et que l'employé n’est pas déjà terminé
|
|
||||||
if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) {
|
|
||||||
return this.archiveOnTermination(active, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sinon, update standard (split Users/Employees)
|
|
||||||
const {
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
email: new_email,
|
|
||||||
phone_number,
|
|
||||||
residence,
|
|
||||||
external_payroll_id,
|
|
||||||
company_code,
|
|
||||||
job_title,
|
|
||||||
first_work_day,
|
|
||||||
last_work_day,
|
|
||||||
supervisor_id,
|
|
||||||
is_supervisor,
|
|
||||||
} = dto as any;
|
|
||||||
|
|
||||||
const first_work_d = toDateOrUndefined(first_work_day);
|
|
||||||
const last_work_d = Object.prototype.hasOwnProperty('last_work_day')
|
|
||||||
? toDateOrNull(last_work_day ?? null)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await this.prisma.$transaction(async (transaction) => {
|
|
||||||
if(
|
|
||||||
first_name !== undefined ||
|
|
||||||
last_name !== undefined ||
|
|
||||||
new_email !== undefined ||
|
|
||||||
phone_number !== undefined ||
|
|
||||||
residence !== undefined
|
|
||||||
) {
|
|
||||||
await transaction.users.update({
|
|
||||||
where: { id: active.user_id },
|
|
||||||
data: {
|
|
||||||
...(first_name !== undefined ? { first_name } : {}),
|
|
||||||
...(last_name !== undefined ? { last_name } : {}),
|
|
||||||
...(email !== undefined ? { email: new_email }: {}),
|
|
||||||
...(phone_number !== undefined ? { phone_number } : {}),
|
|
||||||
...(residence !== undefined ? { residence } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await transaction.employees.update({
|
|
||||||
where: { id: active.id },
|
|
||||||
data: {
|
|
||||||
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
|
|
||||||
...(company_code !== undefined ? { company_code } : {}),
|
|
||||||
...(job_title !== undefined ? { job_title } : {}),
|
|
||||||
...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}),
|
|
||||||
...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}),
|
|
||||||
...(is_supervisor !== undefined ? { is_supervisor } : {}),
|
|
||||||
...(supervisor_id !== undefined ? { supervisor_id } : {}),
|
|
||||||
},
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.prisma.employees.findFirst({ where: { user: {email} } });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.prisma.users.findUnique({where: {email}});
|
|
||||||
if(!user) return null;
|
|
||||||
// 2) Pas trouvé en actifs → regarder en archive (pour restauration)
|
|
||||||
const archived = await this.prisma.employeesArchive.findFirst({
|
|
||||||
where: { user_id: user.id },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (archived) {
|
|
||||||
// Condition de restauration : last_work_day === null ou first_work_day fourni
|
|
||||||
const restore = dto.last_work_day === null || dto.first_work_day != null;
|
|
||||||
if (restore) {
|
|
||||||
return this.restoreEmployee(archived, dto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3) Ni actif, ni archivé → 404 dans le controller
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
//transfers the employee to archive and then delete from employees table
|
|
||||||
private async archiveOnTermination(active: Employees & {user: Users }, dto: UpdateEmployeeDto): Promise<EmployeesArchive> {
|
|
||||||
const last_work_d = toDateOrNull(dto.last_work_day!);
|
|
||||||
if(!last_work_d) throw new Error('invalide last_work_day for archive');
|
|
||||||
return this.prisma.$transaction(async transaction => {
|
|
||||||
//detach crew from supervisor if employee is a supervisor
|
|
||||||
await transaction.employees.updateMany({
|
|
||||||
where: { supervisor_id: active.id },
|
|
||||||
data: { supervisor_id: null },
|
|
||||||
})
|
|
||||||
const archived = await transaction.employeesArchive.create({
|
|
||||||
data: {
|
|
||||||
employee_id: active.id,
|
|
||||||
user_id: active.user_id,
|
|
||||||
first_name: active.user.first_name,
|
|
||||||
last_name: active.user.last_name,
|
|
||||||
external_payroll_id: active.external_payroll_id,
|
|
||||||
company_code: active.company_code,
|
|
||||||
job_title: active.job_title,
|
|
||||||
first_work_day: active.first_work_day,
|
|
||||||
last_work_day: last_work_d,
|
|
||||||
supervisor_id: active.supervisor_id ?? null,
|
|
||||||
is_supervisor: active.is_supervisor,
|
|
||||||
},
|
|
||||||
include: { user: true}
|
|
||||||
});
|
|
||||||
//delete from employees table
|
|
||||||
await transaction.employees.delete({ where: { id: active.id } });
|
|
||||||
//return archived employee
|
|
||||||
return archived
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//transfers the employee from archive to the employees table
|
|
||||||
private async restoreEmployee(archived: EmployeesArchive & { user:Users }, dto: UpdateEmployeeDto): Promise<Employees> {
|
|
||||||
// const first_work_d = toDateOrUndefined(dto.first_work_day);
|
|
||||||
return this.prisma.$transaction(async transaction => {
|
|
||||||
//restores the archived employee into the employees table
|
|
||||||
const restored = await transaction.employees.create({
|
|
||||||
data: {
|
|
||||||
user_id: archived.user_id,
|
|
||||||
external_payroll_id: archived.external_payroll_id,
|
|
||||||
company_code: archived.company_code,
|
|
||||||
job_title: archived.job_title,
|
|
||||||
first_work_day: archived.first_work_day,
|
|
||||||
last_work_day: null,
|
|
||||||
is_supervisor: archived.is_supervisor ?? false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
//deleting archived entry by id
|
|
||||||
await transaction.employeesArchive.delete({ where: { id: archived.id } });
|
|
||||||
|
|
||||||
//return restored employee
|
|
||||||
return restored;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//fetches all archived employees
|
|
||||||
async findAllArchived(): Promise<EmployeesArchive[]> {
|
|
||||||
return this.prisma.employeesArchive.findMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
//fetches an archived employee
|
|
||||||
async findOneArchived(id: number): Promise<EmployeesArchive> {
|
|
||||||
return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
9
src/modules/employees/utils/employee.utils.ts
Normal file
9
src/modules/employees/utils/employee.utils.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function toDateOrNull(v?: string | null): Date | null {
|
||||||
|
if (!v) return null;
|
||||||
|
const day = new Date(v);
|
||||||
|
return isNaN(day.getTime()) ? null : day;
|
||||||
|
}
|
||||||
|
export function toDateOrUndefined(v?: string | null): Date | undefined {
|
||||||
|
const day = toDateOrNull(v ?? undefined);
|
||||||
|
return day === null ? undefined : day;
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Put, } from "@nestjs/common";
|
||||||
import { ExpensesQueryService } from "../services/expenses-query.service";
|
|
||||||
import { CreateExpenseDto } from "../dtos/create-expense.dto";
|
|
||||||
import { Expenses } from "@prisma/client";
|
|
||||||
import { Roles as RoleEnum } from '.prisma/client';
|
import { Roles as RoleEnum } from '.prisma/client';
|
||||||
import { UpdateExpenseDto } from "../dtos/update-expense.dto";
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
import { ExpensesCommandService } from "../services/expenses-command.service";
|
import { ExpensesCommandService } from "../services/expenses-command.service";
|
||||||
import { SearchExpensesDto } from "../dtos/search-expense.dto";
|
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
|
||||||
|
import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
|
||||||
|
import { DayExpensesDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
|
||||||
|
import { ExpensesQueryService } from "../services/expenses-query.service";
|
||||||
|
|
||||||
@ApiTags('Expenses')
|
@ApiTags('Expenses')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
|
|
@ -15,60 +14,82 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto";
|
||||||
@Controller('Expenses')
|
@Controller('Expenses')
|
||||||
export class ExpensesController {
|
export class ExpensesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly expensesService: ExpensesQueryService,
|
private readonly query: ExpensesQueryService,
|
||||||
private readonly expensesApprovalService: ExpensesCommandService,
|
private readonly command: ExpensesCommandService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Put('upsert/:email/:date')
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
async upsert_by_date(
|
||||||
@ApiOperation({ summary: 'Create expense' })
|
@Param('email') email: string,
|
||||||
@ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
|
@Param('date') date: string,
|
||||||
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
@Body() dto: UpsertExpenseDto,
|
||||||
create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
|
): Promise<UpsertExpenseResult> {
|
||||||
return this.expensesService.create(dto);
|
return this.command.upsertExpensesByDate(email, date, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get('list/:email/:year/:period_no')
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
async findExpenseListByPayPeriodAndEmail(
|
||||||
@ApiOperation({ summary: 'Find all expenses' })
|
@Param('email') email:string,
|
||||||
@ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
|
@Param('year') year: number,
|
||||||
@ApiResponse({ status: 400, description: 'List of expenses not found' })
|
@Param('period_no') period_no: number,
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
): Promise<DayExpensesDto> {
|
||||||
findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
|
return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no);
|
||||||
return this.expensesService.findAll(filters);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
//_____________________________________________________________________________________________
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// Deprecated or unused methods
|
||||||
@ApiOperation({ summary: 'Find expense' })
|
//_____________________________________________________________________________________________
|
||||||
@ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
|
|
||||||
@ApiResponse({ status: 400, description: 'Expense not found' })
|
|
||||||
findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
|
|
||||||
return this.expensesService.findOne(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id')
|
// @Post()
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
@ApiOperation({ summary: 'Expense shift' })
|
// @ApiOperation({ summary: 'Create expense' })
|
||||||
@ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
|
// @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
|
||||||
@ApiResponse({ status: 400, description: 'Expense not found' })
|
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||||
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
|
// create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
|
||||||
return this.expensesService.update(id,dto);
|
// return this.query.create(dto);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Delete(':id')
|
// @Get()
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
@ApiOperation({ summary: 'Delete expense' })
|
// @ApiOperation({ summary: 'Find all expenses' })
|
||||||
@ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
|
// @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
|
||||||
@ApiResponse({ status: 400, description: 'Expense not found' })
|
// @ApiResponse({ status: 400, description: 'List of expenses not found' })
|
||||||
remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
|
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
return this.expensesService.remove(id);
|
// findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||||
}
|
// return this.query.findAll(filters);
|
||||||
|
// }
|
||||||
|
|
||||||
@Patch('approval/:id')
|
// @Get(':id')
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
// @ApiOperation({ summary: 'Find expense' })
|
||||||
return this.expensesApprovalService.updateApproval(id, isApproved);
|
// @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
|
||||||
}
|
// @ApiResponse({ status: 400, description: 'Expense not found' })
|
||||||
|
// findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
|
||||||
|
// return this.query.findOne(id);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Patch(':id')
|
||||||
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
// @ApiOperation({ summary: 'Expense shift' })
|
||||||
|
// @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
|
||||||
|
// @ApiResponse({ status: 400, description: 'Expense not found' })
|
||||||
|
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
|
||||||
|
// return this.query.update(id,dto);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Delete(':id')
|
||||||
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
// @ApiOperation({ summary: 'Delete expense' })
|
||||||
|
// @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
|
||||||
|
// @ApiResponse({ status: 400, description: 'Expense not found' })
|
||||||
|
// remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
|
||||||
|
// return this.query.remove(id);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Patch('approval/:id')
|
||||||
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
// async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
||||||
|
// return this.command.updateApproval(id, isApproved);
|
||||||
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +46,7 @@ export class CreateExpenseDto {
|
||||||
description:'explain`s why the expense was made'
|
description:'explain`s why the expense was made'
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
description?: string;
|
comment: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'DENIED, APPROUVED, PENDING, etc...',
|
example: 'DENIED, APPROUVED, PENDING, etc...',
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export class SearchExpensesDto {
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
description_contains?: string;
|
comment_contains?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|
|
||||||
59
src/modules/expenses/dtos/upsert-expense.dto.ts
Normal file
59
src/modules/expenses/dtos/upsert-expense.dto.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
ValidateIf,
|
||||||
|
ValidateNested
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class ExpensePayloadDto {
|
||||||
|
@IsString()
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE')
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amount?: number;
|
||||||
|
|
||||||
|
@ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE')
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
mileage?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(280)
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
comment!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === null || value === undefined || value === '') return undefined;
|
||||||
|
if (typeof value === 'number') return value.toString();
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d+$/)
|
||||||
|
@MaxLength(255)
|
||||||
|
attachment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class UpsertExpenseDto {
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(()=> ExpensePayloadDto)
|
||||||
|
old_expense?: ExpensePayloadDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(()=> ExpensePayloadDto)
|
||||||
|
new_expense?: ExpensePayloadDto;
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,21 @@ import { Module } from "@nestjs/common";
|
||||||
import { ExpensesQueryService } from "./services/expenses-query.service";
|
import { ExpensesQueryService } from "./services/expenses-query.service";
|
||||||
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
||||||
import { ExpensesCommandService } from "./services/expenses-command.service";
|
import { ExpensesCommandService } from "./services/expenses-command.service";
|
||||||
|
import { ExpensesArchivalService } from "./services/expenses-archival.service";
|
||||||
|
import { SharedModule } from "../shared/shared.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BusinessLogicsModule],
|
imports: [BusinessLogicsModule, SharedModule],
|
||||||
controllers: [ExpensesController],
|
controllers: [ExpensesController],
|
||||||
providers: [ExpensesQueryService, ExpensesCommandService],
|
providers: [
|
||||||
exports: [ ExpensesQueryService ],
|
ExpensesQueryService,
|
||||||
|
ExpensesArchivalService,
|
||||||
|
ExpensesCommandService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ExpensesQueryService,
|
||||||
|
ExpensesArchivalService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
export class ExpensesModule {}
|
export class ExpensesModule {}
|
||||||
62
src/modules/expenses/services/expenses-archival.service.ts
Normal file
62
src/modules/expenses/services/expenses-archival.service.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ExpensesArchive } from "@prisma/client";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExpensesArchivalService {
|
||||||
|
constructor(private readonly prisma: PrismaService){}
|
||||||
|
|
||||||
|
async archiveOld(): Promise<void> {
|
||||||
|
//fetches archived timesheet's Ids
|
||||||
|
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
|
||||||
|
select: { timesheet_id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
|
||||||
|
if(timesheet_ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy/delete transaction
|
||||||
|
await this.prisma.$transaction(async transaction => {
|
||||||
|
//fetches expenses to move to archive
|
||||||
|
const expenses_to_archive = await transaction.expenses.findMany({
|
||||||
|
where: { timesheet_id: { in: timesheet_ids } },
|
||||||
|
});
|
||||||
|
if(expenses_to_archive.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//copies sent to archive table
|
||||||
|
await transaction.expensesArchive.createMany({
|
||||||
|
data: expenses_to_archive.map(exp => ({
|
||||||
|
expense_id: exp.id,
|
||||||
|
timesheet_id: exp.timesheet_id,
|
||||||
|
bank_code_id: exp.bank_code_id,
|
||||||
|
date: exp.date,
|
||||||
|
amount: exp.amount,
|
||||||
|
attachment: exp.attachment,
|
||||||
|
comment: exp.comment,
|
||||||
|
is_approved: exp.is_approved,
|
||||||
|
supervisor_comment: exp.supervisor_comment,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
//delete from expenses table
|
||||||
|
await transaction.expenses.deleteMany({
|
||||||
|
where: { id: { in: expenses_to_archive.map(exp => exp.id) } },
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//fetches all archived timesheets
|
||||||
|
async findAllArchived(): Promise<ExpensesArchive[]> {
|
||||||
|
return this.prisma.expensesArchive.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
//fetches an archived timesheet
|
||||||
|
async findOneArchived(id: number): Promise<ExpensesArchive> {
|
||||||
|
return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,38 @@
|
||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { Expenses, Prisma } from "@prisma/client";
|
|
||||||
import { Decimal } from "@prisma/client/runtime/library";
|
|
||||||
import { transcode } from "buffer";
|
|
||||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||||
|
import { Expenses, Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
|
||||||
|
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
|
||||||
|
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
|
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils";
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
assertAndTrimComment,
|
||||||
|
computeAmountDecimal,
|
||||||
|
computeMileageAmount,
|
||||||
|
mapDbExpenseToDayResponse,
|
||||||
|
normalizeType,
|
||||||
|
parseAttachmentId
|
||||||
|
} from "../utils/expenses.utils";
|
||||||
|
import { toDateOnly } from "src/modules/shifts/helpers/shifts-date-time-helpers";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
||||||
constructor(prisma: PrismaService) { super(prisma); }
|
constructor(
|
||||||
|
prisma: PrismaService,
|
||||||
|
private readonly bankCodesResolver: BankCodesResolver,
|
||||||
|
private readonly timesheetsResolver: EmployeeTimesheetResolver,
|
||||||
|
private readonly emailResolver: EmailToIdResolver,
|
||||||
|
) { super(prisma); }
|
||||||
|
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
// APPROVAL TX-DELEGATE METHODS
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
|
||||||
protected get delegate() {
|
protected get delegate() {
|
||||||
return this.prisma.expenses;
|
return this.prisma.expenses;
|
||||||
|
|
@ -23,16 +48,203 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// deprecated since batch transaction are made with timesheets
|
//_____________________________________________________________________________________________
|
||||||
// async updateManyWithTx(
|
// MASTER CRUD FUNCTION
|
||||||
// tx: Prisma.TransactionClient,
|
//_____________________________________________________________________________________________
|
||||||
// ids: number[],
|
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
|
||||||
// isApproved: boolean,
|
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
|
||||||
// ): Promise<number> {
|
|
||||||
// const { count } = await tx.expenses.updateMany({
|
//validates if there is an existing expense, at least 1 old or new
|
||||||
// where: { id: { in: ids } },
|
const { old_expense, new_expense } = dto ?? {};
|
||||||
// data: { is_approved: isApproved },
|
if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
|
||||||
// });
|
|
||||||
// return count;
|
//validate date format
|
||||||
// }
|
const date_only = toDateOnly(date);
|
||||||
|
if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
|
||||||
|
|
||||||
|
//resolve employee_id by email
|
||||||
|
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
|
||||||
|
//make sure a timesheet existes
|
||||||
|
const timesheet_id = await this.timesheetsResolver.ensureForDate(employee_id, date_only);
|
||||||
|
if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
|
||||||
|
const {id} = timesheet_id;
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const loadDay = async (): Promise<ExpenseResponse[]> => {
|
||||||
|
const rows = await tx.expenses.findMany({
|
||||||
|
where: {
|
||||||
|
timesheet_id: id,
|
||||||
|
date: date_only,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
bank_code: {
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ date: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((r) =>
|
||||||
|
mapDbExpenseToDayResponse({
|
||||||
|
date: r.date,
|
||||||
|
amount: r.amount ?? 0,
|
||||||
|
mileage: r.mileage ?? 0,
|
||||||
|
comment: r.comment,
|
||||||
|
is_approved: r.is_approved,
|
||||||
|
bank_code: r.bank_code,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePayload = async (payload: {
|
||||||
|
type: string;
|
||||||
|
amount?: number;
|
||||||
|
mileage?: number;
|
||||||
|
comment: string;
|
||||||
|
attachment?: string | number;
|
||||||
|
}): Promise<{
|
||||||
|
type: string;
|
||||||
|
bank_code_id: number;
|
||||||
|
amount: Prisma.Decimal;
|
||||||
|
mileage: number | null;
|
||||||
|
comment: string;
|
||||||
|
attachment: number | null;
|
||||||
|
}> => {
|
||||||
|
const type = normalizeType(payload.type);
|
||||||
|
const comment = assertAndTrimComment(payload.comment);
|
||||||
|
const attachment = parseAttachmentId(payload.attachment);
|
||||||
|
|
||||||
|
const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
|
||||||
|
let amount = computeAmountDecimal(type, payload, modifier);
|
||||||
|
let mileage: number | null = null;
|
||||||
|
|
||||||
|
if (type === 'MILEAGE') {
|
||||||
|
mileage = Number(payload.mileage ?? 0);
|
||||||
|
if (!(mileage > 0)) {
|
||||||
|
throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountNumber = computeMileageAmount(mileage, modifier);
|
||||||
|
amount = new Prisma.Decimal(amountNumber);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
|
||||||
|
throw new BadRequestException('Amount required for non-MILEAGE expense');
|
||||||
|
}
|
||||||
|
amount = new Prisma.Decimal(payload.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment !== null) {
|
||||||
|
const attachment_row = await tx.attachments.findUnique({
|
||||||
|
where: { id: attachment },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
if (!attachment_row || attachment_row.status !== 'ACTIVE') {
|
||||||
|
throw new BadRequestException('Attachment not found or inactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
bank_code_id,
|
||||||
|
amount,
|
||||||
|
mileage,
|
||||||
|
comment,
|
||||||
|
attachment
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findExactOld = async (norm: {
|
||||||
|
bank_code_id: number;
|
||||||
|
amount: Prisma.Decimal;
|
||||||
|
mileage: number | null;
|
||||||
|
comment: string;
|
||||||
|
attachment: number | null;
|
||||||
|
}) => {
|
||||||
|
return tx.expenses.findFirst({
|
||||||
|
where: {
|
||||||
|
timesheet_id: id,
|
||||||
|
date: date_only,
|
||||||
|
bank_code_id: norm.bank_code_id,
|
||||||
|
amount: norm.amount,
|
||||||
|
comment: norm.comment,
|
||||||
|
attachment: norm.attachment,
|
||||||
|
...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let action : UpsertAction;
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
// DELETE
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
if(old_expense && !new_expense) {
|
||||||
|
const old_norm = await normalizePayload(old_expense);
|
||||||
|
const existing = await findExactOld(old_norm);
|
||||||
|
if(!existing) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
error_code: 'EXPENSE_STALE',
|
||||||
|
message: 'The expense was modified or deleted by someone else',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.expenses.delete({where: { id: existing.id } });
|
||||||
|
action = 'delete';
|
||||||
|
}
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
// CREATE
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
else if (!old_expense && new_expense) {
|
||||||
|
const new_exp = await normalizePayload(new_expense);
|
||||||
|
await tx.expenses.create({
|
||||||
|
data: {
|
||||||
|
timesheet_id: id,
|
||||||
|
date: date_only,
|
||||||
|
bank_code_id: new_exp.bank_code_id,
|
||||||
|
amount: new_exp.amount,
|
||||||
|
mileage: new_exp.mileage,
|
||||||
|
comment: new_exp.comment,
|
||||||
|
attachment: new_exp.attachment,
|
||||||
|
is_approved: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
action = 'create';
|
||||||
|
}
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
// UPDATE
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
else if(old_expense && new_expense) {
|
||||||
|
const old_norm = await normalizePayload(old_expense);
|
||||||
|
const existing = await findExactOld(old_norm);
|
||||||
|
if(!existing) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
error_code: 'EXPENSE_STALE',
|
||||||
|
message: 'The expense was modified or deleted by someone else',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const new_exp = await normalizePayload(new_expense);
|
||||||
|
await tx.expenses.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
bank_code_id: new_exp.bank_code_id,
|
||||||
|
amount: new_exp.amount,
|
||||||
|
mileage: new_exp.mileage,
|
||||||
|
comment: new_exp.comment,
|
||||||
|
attachment: new_exp.attachment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
action = 'update';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new BadRequestException('Invalid upsert combination');
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = await loadDay();
|
||||||
|
|
||||||
|
return { action, day };
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,148 +1,174 @@
|
||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { CreateExpenseDto } from "../dtos/create-expense.dto";
|
import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
|
||||||
import { Expenses, ExpensesArchive } from "@prisma/client";
|
import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers";
|
||||||
import { UpdateExpenseDto } from "../dtos/update-expense.dto";
|
import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types";
|
||||||
import { MileageService } from "src/modules/business-logics/services/mileage.service";
|
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
import { SearchExpensesDto } from "../dtos/search-expense.dto";
|
|
||||||
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpensesQueryService {
|
export class ExpensesQueryService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly mileageService: MileageService,
|
private readonly employeeRepo: EmailToIdResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateExpenseDto): Promise<Expenses> {
|
|
||||||
const { timesheet_id, bank_code_id, date, amount:rawAmount,
|
|
||||||
description, is_approved,supervisor_comment} = dto;
|
|
||||||
|
|
||||||
|
//fetchs all expenses for a selected employee using email, pay-period-year and number
|
||||||
|
async findExpenseListByPayPeriodAndEmail(
|
||||||
|
email: string,
|
||||||
|
year: number,
|
||||||
|
period_no: number
|
||||||
|
): Promise<ExpenseListResponseDto> {
|
||||||
|
//fetch employe_id using email
|
||||||
|
const employee_id = await this.employeeRepo.findIdByEmail(email);
|
||||||
|
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
|
||||||
|
|
||||||
//fetches type and modifier
|
//fetch pay-period using year and period_no
|
||||||
const bank_code = await this.prisma.bankCodes.findUnique({
|
const pay_period = await this.prisma.payPeriods.findFirst({
|
||||||
where: { id: bank_code_id },
|
where: {
|
||||||
select: { type: true, modifier: true },
|
pay_year: year,
|
||||||
});
|
pay_period_no: period_no
|
||||||
if(!bank_code) {
|
|
||||||
throw new NotFoundException(`bank_code #${bank_code_id} not found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
//if mileage -> service, otherwise the ratio is amount:1
|
|
||||||
let final_amount: number;
|
|
||||||
if(bank_code.type === 'mileage') {
|
|
||||||
final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
|
|
||||||
}else {
|
|
||||||
final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.expenses.create({
|
|
||||||
data: { timesheet_id, bank_code_id, date, amount: final_amount, description, is_approved, supervisor_comment},
|
|
||||||
include: { timesheet: { include: { employee: { include: { user: true }}}},
|
|
||||||
bank_code: true,
|
|
||||||
},
|
},
|
||||||
})
|
select: { period_start: true, period_end: true },
|
||||||
}
|
});
|
||||||
|
if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`);
|
||||||
|
|
||||||
async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
|
const start = toUTCDateOnly(pay_period.period_start);
|
||||||
const where = buildPrismaWhere(filters);
|
const end = toUTCDateOnly(pay_period.period_end);
|
||||||
const expenses = await this.prisma.expenses.findMany({ where })
|
|
||||||
return expenses;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(id: number): Promise<Expenses> {
|
//sets rows data
|
||||||
const expense = await this.prisma.expenses.findUnique({
|
const rows = await this.prisma.expenses.findMany({
|
||||||
where: { id },
|
where: {
|
||||||
include: { timesheet: { include: { employee: { include: { user:true } } } },
|
date: { gte: start, lte: end },
|
||||||
bank_code: true,
|
timesheet: { is: { employee_id } },
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc'},
|
||||||
|
select: {
|
||||||
|
amount: true,
|
||||||
|
mileage: true,
|
||||||
|
comment: true,
|
||||||
|
is_approved: true,
|
||||||
|
supervisor_comment: true,
|
||||||
|
bank_code: {select: { type: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!expense) {
|
|
||||||
throw new NotFoundException(`Expense #${id} not found`);
|
//declare return values
|
||||||
}
|
const expenses: ExpenseDto[] = [];
|
||||||
return expense;
|
let total_amount = 0;
|
||||||
|
let total_mileage = 0;
|
||||||
|
|
||||||
|
//set rows
|
||||||
|
for(const row of rows) {
|
||||||
|
const type = (row.bank_code?.type ?? '').toUpperCase();
|
||||||
|
const amount = round2(Number(row.amount ?? 0));
|
||||||
|
const mileage = round2(Number(row.mileage ?? 0));
|
||||||
|
|
||||||
|
if(type === EXPENSE_TYPES.MILEAGE) {
|
||||||
|
total_mileage += mileage;
|
||||||
|
} else {
|
||||||
|
total_amount += amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
|
//fills rows array
|
||||||
await this.findOne(id);
|
expenses.push({
|
||||||
const { timesheet_id, bank_code_id, date, amount,
|
type,
|
||||||
description, is_approved, supervisor_comment} = dto;
|
amount,
|
||||||
return this.prisma.expenses.update({
|
mileage,
|
||||||
where: { id },
|
comment: row.comment ?? '',
|
||||||
data: {
|
is_approved: row.is_approved ?? false,
|
||||||
...(timesheet_id !== undefined && { timesheet_id}),
|
supervisor_comment: row.supervisor_comment ?? '',
|
||||||
...(bank_code_id !== undefined && { bank_code_id }),
|
|
||||||
...(date !== undefined && { date }),
|
|
||||||
...(amount !== undefined && { amount }),
|
|
||||||
...(description !== undefined && { description }),
|
|
||||||
...(is_approved !== undefined && { is_approved }),
|
|
||||||
...(supervisor_comment !== undefined && { supervisor_comment }),
|
|
||||||
},
|
|
||||||
include: { timesheet: { include: { employee: { include: { user: true } } } },
|
|
||||||
bank_code: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number): Promise<Expenses> {
|
return {
|
||||||
await this.findOne(id);
|
expenses,
|
||||||
return this.prisma.expenses.delete({ where: { id } });
|
total_expense: round2(total_amount),
|
||||||
}
|
total_mileage: round2(total_mileage),
|
||||||
|
};
|
||||||
|
}
|
||||||
//archivation functions ******************************************************
|
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
async archiveOld(): Promise<void> {
|
// Deprecated or unused methods
|
||||||
//fetches archived timesheet's Ids
|
//_____________________________________________________________________________________________
|
||||||
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
|
|
||||||
select: { timesheet_id: true },
|
// async create(dto: CreateExpenseDto): Promise<Expenses> {
|
||||||
});
|
// const { timesheet_id, bank_code_id, date, amount:rawAmount,
|
||||||
|
// comment, is_approved,supervisor_comment} = dto;
|
||||||
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
|
// //fetches type and modifier
|
||||||
if(timesheet_ids.length === 0) {
|
// const bank_code = await this.prisma.bankCodes.findUnique({
|
||||||
return;
|
// where: { id: bank_code_id },
|
||||||
}
|
// select: { type: true, modifier: true },
|
||||||
|
// });
|
||||||
// copy/delete transaction
|
// if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
|
||||||
await this.prisma.$transaction(async transaction => {
|
|
||||||
//fetches expenses to move to archive
|
// //if mileage -> service, otherwise the ratio is amount:1
|
||||||
const expenses_to_archive = await transaction.expenses.findMany({
|
// let final_amount: number;
|
||||||
where: { timesheet_id: { in: timesheet_ids } },
|
// if(bank_code.type === 'mileage') {
|
||||||
});
|
// final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
|
||||||
if(expenses_to_archive.length === 0) {
|
// }else {
|
||||||
return;
|
// final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
|
||||||
}
|
// }
|
||||||
|
|
||||||
//copies sent to archive table
|
// return this.prisma.expenses.create({
|
||||||
await transaction.expensesArchive.createMany({
|
// data: {
|
||||||
data: expenses_to_archive.map(exp => ({
|
// timesheet_id,
|
||||||
expense_id: exp.id,
|
// bank_code_id,
|
||||||
timesheet_id: exp.timesheet_id,
|
// date,
|
||||||
bank_code_id: exp.bank_code_id,
|
// amount: final_amount,
|
||||||
date: exp.date,
|
// comment,
|
||||||
amount: exp.amount,
|
// is_approved,
|
||||||
attachement: exp.attachement,
|
// supervisor_comment
|
||||||
description: exp.description,
|
// },
|
||||||
is_approved: exp.is_approved,
|
// include: { timesheet: { include: { employee: { include: { user: true }}}},
|
||||||
supervisor_comment: exp.supervisor_comment,
|
// bank_code: true,
|
||||||
})),
|
// },
|
||||||
});
|
// })
|
||||||
|
// }
|
||||||
//delete from expenses table
|
|
||||||
await transaction.expenses.deleteMany({
|
// async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||||
where: { id: { in: expenses_to_archive.map(exp => exp.id) } },
|
// const where = buildPrismaWhere(filters);
|
||||||
})
|
// const expenses = await this.prisma.expenses.findMany({ where })
|
||||||
|
// return expenses;
|
||||||
})
|
// }
|
||||||
}
|
|
||||||
|
// async findOne(id: number): Promise<Expenses> {
|
||||||
//fetches all archived timesheets
|
// const expense = await this.prisma.expenses.findUnique({
|
||||||
async findAllArchived(): Promise<ExpensesArchive[]> {
|
// where: { id },
|
||||||
return this.prisma.expensesArchive.findMany();
|
// include: { timesheet: { include: { employee: { include: { user:true } } } },
|
||||||
}
|
// bank_code: true,
|
||||||
|
// },
|
||||||
//fetches an archived timesheet
|
// });
|
||||||
async findOneArchived(id: number): Promise<ExpensesArchive> {
|
// if (!expense) {
|
||||||
return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } });
|
// throw new NotFoundException(`Expense #${id} not found`);
|
||||||
}
|
// }
|
||||||
|
// return expense;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
|
||||||
|
// await this.findOne(id);
|
||||||
|
// const { timesheet_id, bank_code_id, date, amount,
|
||||||
|
// comment, is_approved, supervisor_comment} = dto;
|
||||||
|
// return this.prisma.expenses.update({
|
||||||
|
// where: { id },
|
||||||
|
// data: {
|
||||||
|
// ...(timesheet_id !== undefined && { timesheet_id}),
|
||||||
|
// ...(bank_code_id !== undefined && { bank_code_id }),
|
||||||
|
// ...(date !== undefined && { date }),
|
||||||
|
// ...(amount !== undefined && { amount }),
|
||||||
|
// ...(comment !== undefined && { comment }),
|
||||||
|
// ...(is_approved !== undefined && { is_approved }),
|
||||||
|
// ...(supervisor_comment !== undefined && { supervisor_comment }),
|
||||||
|
// },
|
||||||
|
// include: { timesheet: { include: { employee: { include: { user: true } } } },
|
||||||
|
// bank_code: true,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async remove(id: number): Promise<Expenses> {
|
||||||
|
// await this.findOne(id);
|
||||||
|
// return this.prisma.expenses.delete({ where: { id } });
|
||||||
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
export type UpsertAction = 'create' | 'update' | 'delete';
|
||||||
|
|
||||||
|
export interface ExpenseResponse {
|
||||||
|
date: string;
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
comment: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpsertExpenseResult = {
|
||||||
|
action: UpsertAction;
|
||||||
|
day: ExpenseResponse[]
|
||||||
|
};
|
||||||
111
src/modules/expenses/utils/expenses.utils.ts
Normal file
111
src/modules/expenses/utils/expenses.utils.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { ExpenseResponse } from "../types and interfaces/expenses.types.interfaces";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
//uppercase and trim for validation
|
||||||
|
export function normalizeType(type: string): string {
|
||||||
|
return (type ?? '').trim().toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
//required comment after trim
|
||||||
|
export function assertAndTrimComment(comment: string): string {
|
||||||
|
const cmt = (comment ?? '').trim();
|
||||||
|
if(cmt.length === 0) {
|
||||||
|
throw new BadRequestException('A comment is required');
|
||||||
|
}
|
||||||
|
return cmt;
|
||||||
|
};
|
||||||
|
|
||||||
|
//rounding $ to 2 decimals
|
||||||
|
export function roundMoney2(num: number): number {
|
||||||
|
return Math.round((num + Number.EPSILON) * 100)/ 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function computeMileageAmount(km: number, modifier: number): number {
|
||||||
|
if(km < 0) throw new BadRequestException('mileage must be positive');
|
||||||
|
if(modifier < 0) throw new BadRequestException('modifier must be positive');
|
||||||
|
return roundMoney2(km * modifier);
|
||||||
|
};
|
||||||
|
|
||||||
|
//compat. types with Prisma.Decimal. work around Prisma import in utils.
|
||||||
|
export type DecimalLike =
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| { toNumber?: () => number }
|
||||||
|
| { toString?: () => string };
|
||||||
|
|
||||||
|
|
||||||
|
//safe conversion to number
|
||||||
|
export function toNumberSafe(value: DecimalLike): number {
|
||||||
|
if(typeof value === 'number') return value;
|
||||||
|
if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber();
|
||||||
|
return Number(
|
||||||
|
typeof (value as any)?.toString === 'function'
|
||||||
|
? (value as any).toString()
|
||||||
|
: value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseAttachmentId = (value: unknown): number | null => {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new BadRequestException('Invalid attachment id');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.length) return null;
|
||||||
|
if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id');
|
||||||
|
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
if (parsed <= 0) throw new BadRequestException('Invalid attachment id');
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
throw new BadRequestException('Invalid attachment id');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
//map of a row for DayExpenseResponse
|
||||||
|
export function mapDbExpenseToDayResponse(row: {
|
||||||
|
date: Date;
|
||||||
|
amount: Prisma.Decimal | number | string | null;
|
||||||
|
mileage?: Prisma.Decimal | number | string | null;
|
||||||
|
comment: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
bank_code?: { type?: string | null } | null;
|
||||||
|
}): ExpenseResponse {
|
||||||
|
const yyyyMmDd = row.date.toISOString().slice(0,10);
|
||||||
|
const toNum = (value: any)=> (value == null ? 0 : Number(value));
|
||||||
|
return {
|
||||||
|
date: yyyyMmDd,
|
||||||
|
type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'),
|
||||||
|
amount: toNum(row.amount),
|
||||||
|
comment: row.comment,
|
||||||
|
is_approved: row.is_approved,
|
||||||
|
...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const computeAmountDecimal = (
|
||||||
|
type: string,
|
||||||
|
payload: {
|
||||||
|
amount?: number;
|
||||||
|
mileage?: number;
|
||||||
|
},
|
||||||
|
modifier: number,
|
||||||
|
): Prisma.Decimal => {
|
||||||
|
if(type === 'MILEAGE') {
|
||||||
|
const km = payload.mileage ?? 0;
|
||||||
|
const amountNumber = computeMileageAmount(km, modifier);
|
||||||
|
return new Prisma.Decimal(amountNumber);
|
||||||
|
}
|
||||||
|
return new Prisma.Decimal(payload.amount!);
|
||||||
|
};
|
||||||
|
|
@ -2,8 +2,9 @@ import { Controller, Get, Header, Query, UseGuards } from "@nestjs/common";
|
||||||
import { RolesGuard } from "src/common/guards/roles.guard";
|
import { RolesGuard } from "src/common/guards/roles.guard";
|
||||||
import { Roles as RoleEnum } from '.prisma/client';
|
import { Roles as RoleEnum } from '.prisma/client';
|
||||||
import { CsvExportService } from "../services/csv-exports.service";
|
import { CsvExportService } from "../services/csv-exports.service";
|
||||||
import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto";
|
// import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto";
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
|
import { ExportCsvOptionsDto } from "../dtos/export-csv-options.dto";
|
||||||
|
|
||||||
|
|
||||||
@Controller('exports')
|
@Controller('exports')
|
||||||
|
|
@ -13,33 +14,27 @@ export class CsvExportController {
|
||||||
|
|
||||||
@Get('csv')
|
@Get('csv')
|
||||||
@Header('Content-Type', 'text/csv; charset=utf-8')
|
@Header('Content-Type', 'text/csv; charset=utf-8')
|
||||||
@Header('Content-Dispoition', 'attachment; filename="export.csv"')
|
@Header('Content-Disposition', 'attachment; filename="export.csv"')
|
||||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR)
|
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR)
|
||||||
async exportCsv(@Query() options: ExportCsvOptionsDto,
|
async exportCsv(@Query() query: ExportCsvOptionsDto ): Promise<Buffer> {
|
||||||
@Query('period') periodId: string ): Promise<Buffer> {
|
const rows = await this.csvService.collectTransaction(
|
||||||
|
query.year,
|
||||||
//sets default values
|
query.period_no,
|
||||||
const companies = options.companies && options.companies.length ? options.companies :
|
{
|
||||||
[ ExportCompany.TARGO, ExportCompany.SOLUCOM];
|
approved: query.approved ?? true,
|
||||||
const types = options.type && options.type.length ? options.type :
|
types: {
|
||||||
Object.values(ExportType);
|
shifts: query.shifts ?? true,
|
||||||
|
expenses: query.expenses ?? true,
|
||||||
//collects all
|
holiday: query.holiday ?? true,
|
||||||
const all = await this.csvService.collectTransaction(Number(periodId), companies);
|
vacation: query.vacation ?? true,
|
||||||
|
},
|
||||||
//filters by type
|
companies: {
|
||||||
const filtered = all.filter(r => {
|
targo: query.targo ?? true,
|
||||||
switch (r.bank_code.toLocaleLowerCase()) {
|
solucom: query.solucom ?? true,
|
||||||
case 'holiday' : return types.includes(ExportType.HOLIDAY);
|
},
|
||||||
case 'vacation' : return types.includes(ExportType.VACATION);
|
|
||||||
case 'sick-leave': return types.includes(ExportType.SICK_LEAVE);
|
|
||||||
case 'expenses' : return types.includes(ExportType.EXPENSES);
|
|
||||||
default : return types.includes(ExportType.SHIFTS);
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
return this.csvService.generateCsv(rows);
|
||||||
//generating the csv file
|
|
||||||
return this.csvService.generateCsv(filtered);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { CsvExportController } from "./controllers/csv-exports.controller";
|
import { CsvExportController } from "./controllers/csv-exports.controller";
|
||||||
import { CsvExportService } from "./services/csv-exports.service";
|
import { CsvExportService } from "./services/csv-exports.service";
|
||||||
|
import { SharedModule } from "../shared/shared.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers:[CsvExportService],
|
providers:[CsvExportService, SharedModule],
|
||||||
controllers: [CsvExportController],
|
controllers: [CsvExportController],
|
||||||
})
|
})
|
||||||
export class CsvExportModule {}
|
export class CsvExportModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,47 @@
|
||||||
import { IsArray, IsEnum, IsOptional } from "class-validator";
|
import { Transform } from "class-transformer";
|
||||||
|
import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator";
|
||||||
|
|
||||||
export enum ExportType {
|
function toBoolean(v: any) {
|
||||||
SHIFTS = 'Quart de travail',
|
if(typeof v === 'boolean') return v;
|
||||||
EXPENSES = 'Depenses',
|
if(typeof v === 'string') return ['true', '1', 'on','yes'].includes(v.toLowerCase());
|
||||||
HOLIDAY = 'Ferie',
|
return false;
|
||||||
VACATION = 'Vacance',
|
|
||||||
SICK_LEAVE = 'Absence'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ExportCompany {
|
|
||||||
TARGO = 'Targo',
|
|
||||||
SOLUCOM = 'Solucom',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExportCsvOptionsDto {
|
export class ExportCsvOptionsDto {
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsEnum(ExportCompany, { each: true })
|
|
||||||
companies?: ExportCompany[];
|
|
||||||
|
|
||||||
@IsOptional()
|
@Transform(({ value }) => parseInt(value,10))
|
||||||
@IsArray()
|
@IsInt() @Min(2023)
|
||||||
@IsEnum(ExportType, { each: true })
|
year! : number;
|
||||||
type?: ExportType[];
|
|
||||||
|
@Transform(({ value }) => parseInt(value,10))
|
||||||
|
@IsInt() @Min(1) @Max(26)
|
||||||
|
period_no!: number;
|
||||||
|
|
||||||
|
@IsOptional() @IsBoolean()
|
||||||
|
@Transform(({ value }) => toBoolean(value))
|
||||||
|
approved? : boolean = true;
|
||||||
|
|
||||||
|
@IsOptional() @IsBoolean()
|
||||||
|
@Transform(({ value }) => toBoolean(value))
|
||||||
|
shifts? : boolean = true;
|
||||||
|
|
||||||
|
@IsOptional() @IsBoolean()
|
||||||
|
@Transform(({ value }) => toBoolean(value))
|
||||||
|
expenses? : boolean = true;
|
||||||
|
|
||||||
|
@IsOptional() @IsBoolean()
|
||||||
|
@Transform(({ value }) => toBoolean(value))
|
||||||
|
holiday? : boolean = true;
|
||||||
|
|
||||||
|
@IsOptional() @IsBoolean()
|
||||||
|
@Transform(({ value }) => toBoolean(value))
|
||||||
|
vacation? : boolean = true;
|
||||||
|
|
||||||
|
@IsOptional() @IsBoolean()
|
||||||
|
@Transform(({ value }) => toBoolean(value))
|
||||||
|
targo? : boolean = true;
|
||||||
|
|
||||||
|
@IsOptional() @IsBoolean()
|
||||||
|
@Transform(({ value }) => toBoolean(value))
|
||||||
|
solucom? : boolean = true;
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ExportCompany } from "../dtos/export-csv-options.dto";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
||||||
|
|
||||||
export interface CsvRow {
|
export interface CsvRow {
|
||||||
company_code: number;
|
company_code: number;
|
||||||
|
|
@ -14,148 +13,240 @@ export interface CsvRow {
|
||||||
holiday_date?: string;
|
holiday_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
types: {
|
||||||
|
shifts: boolean;
|
||||||
|
expenses: boolean;
|
||||||
|
holiday: boolean;
|
||||||
|
vacation: boolean;
|
||||||
|
};
|
||||||
|
companies: {
|
||||||
|
targo: boolean;
|
||||||
|
solucom: boolean;
|
||||||
|
};
|
||||||
|
approved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CsvExportService {
|
export class CsvExportService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true):
|
async collectTransaction(
|
||||||
Promise<CsvRow[]> {
|
year: number,
|
||||||
|
period_no: number,
|
||||||
const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2);
|
filters: Filters,
|
||||||
|
approved: boolean = true
|
||||||
|
): Promise<CsvRow[]> {
|
||||||
|
//fetch period
|
||||||
const period = await this.prisma.payPeriods.findFirst({
|
const period = await this.prisma.payPeriods.findFirst({
|
||||||
where: { pay_period_no: period_id },
|
where: { pay_year: year, pay_period_no: period_no },
|
||||||
|
select: { period_start: true, period_end: true },
|
||||||
});
|
});
|
||||||
if(!period) throw new NotFoundException(`Pay period ${period_id} not found`);
|
if(!period) throw new NotFoundException(`Pay period ${ year }-${ period_no } not found`);
|
||||||
|
|
||||||
const start_date = period.period_start;
|
const start = period.period_start;
|
||||||
const end_date = period.period_end;
|
const end = period.period_end;
|
||||||
|
|
||||||
const approved_filter = approved ? { is_approved: true } : {};
|
//fetch company codes from .env
|
||||||
|
const company_codes = this.resolveCompanyCodes(filters.companies);
|
||||||
|
if(company_codes.length === 0) throw new BadRequestException('No company selected');
|
||||||
|
|
||||||
//fetching shifts
|
//Flag types
|
||||||
const shifts = await this.prisma.shifts.findMany({
|
const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types;
|
||||||
|
if(!want_shifts && !want_expense && !want_holiday && !want_vacation) {
|
||||||
|
throw new BadRequestException(' No export type selected ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved_filter = filters.approved? { is_approved: true } : {};
|
||||||
|
|
||||||
|
const {holiday_code, vacation_code} = this.resolveLeaveCodes();
|
||||||
|
|
||||||
|
//Prisma queries
|
||||||
|
const promises: Array<Promise<any[]>> = [];
|
||||||
|
|
||||||
|
if (want_shifts) {
|
||||||
|
promises.push( this.prisma.shifts.findMany({
|
||||||
where: {
|
where: {
|
||||||
date: { gte: start_date, lte: end_date },
|
date: { gte: start, lte: end },
|
||||||
...approved_filter,
|
...approved_filter,
|
||||||
timesheet: {
|
bank_code: { bank_code: { notIn: [ holiday_code, vacation_code ] } },
|
||||||
employee: { company_code: { in: company_codes} } },
|
timesheet: { employee: { company_code: { in: company_codes } } },
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
bank_code: true,
|
date: true,
|
||||||
timesheet: { include: {
|
start_time: true,
|
||||||
employee: { include: {
|
end_time: true,
|
||||||
user:true,
|
bank_code: { select: { bank_code: true } },
|
||||||
supervisor: { include: {
|
timesheet: { select: {
|
||||||
user:true } } } } } },
|
employee: { select: {
|
||||||
|
company_code: true,
|
||||||
|
external_payroll_id: true,
|
||||||
|
user: { select: { first_name: true, last_name: true } },
|
||||||
|
}},
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
} else {
|
||||||
|
promises.push(Promise.resolve([]));
|
||||||
|
}
|
||||||
|
|
||||||
//fetching expenses
|
if(want_holiday) {
|
||||||
const expenses = await this.prisma.expenses.findMany({
|
promises.push( this.prisma.shifts.findMany({
|
||||||
where: {
|
where: {
|
||||||
date: { gte: start_date, lte: end_date },
|
date: { gte: start, lte: end },
|
||||||
...approved_filter,
|
...approved_filter,
|
||||||
timesheet: { employee: { company_code: { in: company_codes} } },
|
bank_code: { bank_code: holiday_code },
|
||||||
|
timesheet: { employee: { company_code: { in: company_codes } } },
|
||||||
},
|
},
|
||||||
include: { bank_code: true,
|
select: {
|
||||||
timesheet: { include: {
|
date: true,
|
||||||
employee: { include: {
|
start_time: true,
|
||||||
user: true,
|
end_time: true,
|
||||||
supervisor: { include: {
|
bank_code: { select: { bank_code: true } },
|
||||||
user:true } } } } } },
|
timesheet: { select: {
|
||||||
|
employee: { select: {
|
||||||
|
company_code: true,
|
||||||
|
external_payroll_id: true,
|
||||||
|
user: { select: { first_name: true,last_name: true } },
|
||||||
|
} },
|
||||||
|
} },
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
}else {
|
||||||
|
promises.push(Promise.resolve([]));
|
||||||
|
}
|
||||||
|
|
||||||
//fetching leave requests
|
if(want_vacation) {
|
||||||
const leaves = await this.prisma.leaveRequests.findMany({
|
promises.push( this.prisma.shifts.findMany({
|
||||||
where : {
|
where: {
|
||||||
start_date_time: { gte: start_date, lte: end_date },
|
date: { gte: start, lte: end },
|
||||||
employee: { company_code: { in: company_codes } },
|
...approved_filter,
|
||||||
|
bank_code: { bank_code: vacation_code },
|
||||||
|
timesheet: { employee: { company_code: { in: company_codes } } },
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
bank_code: true,
|
date: true,
|
||||||
employee: { include: {
|
start_time: true,
|
||||||
user: true,
|
end_time: true,
|
||||||
supervisor: { include: {
|
bank_code: { select: { bank_code: true } },
|
||||||
user: true } } } },
|
timesheet: { select: {
|
||||||
|
employee: { select: {
|
||||||
|
company_code: true,
|
||||||
|
external_payroll_id: true,
|
||||||
|
user: { select: { first_name: true,last_name: true } },
|
||||||
|
} },
|
||||||
|
} },
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
}else {
|
||||||
|
promises.push(Promise.resolve([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(want_expense) {
|
||||||
|
promises.push( this.prisma.expenses.findMany({
|
||||||
|
where: {
|
||||||
|
date: { gte: start, lte: end },
|
||||||
|
...approved_filter,
|
||||||
|
timesheet: { employee: { company_code: { in: company_codes } } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
date: true,
|
||||||
|
amount: true,
|
||||||
|
bank_code: { select: { bank_code: true } },
|
||||||
|
timesheet: { select: {
|
||||||
|
employee: { select: {
|
||||||
|
company_code: true,
|
||||||
|
external_payroll_id: true,
|
||||||
|
user: { select: { first_name: true, last_name: true } },
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
promises.push(Promise.resolve([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
//array of arrays
|
||||||
|
const [ base_shifts, holiday_shifts, vacation_shifts, expenses ] = await Promise.all(promises);
|
||||||
|
//mapping
|
||||||
const rows: CsvRow[] = [];
|
const rows: CsvRow[] = [];
|
||||||
|
|
||||||
//Shifts Mapping
|
const map_shifts = (shift: any, is_holiday: boolean) => {
|
||||||
for (const shift of shifts) {
|
const employee = shift.timesheet.employee;
|
||||||
const emp = shift.timesheet.employee;
|
const week = this.computeWeekNumber(start, shift.date);
|
||||||
const week_number = this.computeWeekNumber(start_date, shift.date);
|
return {
|
||||||
const hours = this.computeHours(shift.start_time, shift.end_time);
|
company_code: employee.company_code,
|
||||||
|
external_payroll_id: employee.external_payroll_id,
|
||||||
rows.push({
|
full_name: `${employee.first_name} ${ employee.last_name}`,
|
||||||
company_code: emp.company_code,
|
bank_code: shift.bank_code?.bank_code ?? '',
|
||||||
external_payroll_id: emp.external_payroll_id,
|
quantity_hours: this.computeHours(shift.start_time, shift.end_time),
|
||||||
full_name: `${emp.user.first_name} ${emp.user.last_name}`,
|
|
||||||
bank_code: shift.bank_code.bank_code,
|
|
||||||
quantity_hours: hours,
|
|
||||||
amount: undefined,
|
amount: undefined,
|
||||||
week_number,
|
week_number: week,
|
||||||
pay_date: this.formatDate(end_date),
|
pay_date: this.formatDate(end),
|
||||||
holiday_date: undefined,
|
holiday_date: is_holiday? this.formatDate(shift.date) : '',
|
||||||
});
|
} as CsvRow;
|
||||||
}
|
};
|
||||||
|
//final mapping of all shifts based filters
|
||||||
//Expenses Mapping
|
for (const shift of base_shifts) rows.push(map_shifts(shift, false));
|
||||||
for (const e of expenses) {
|
for (const shift of holiday_shifts) rows.push(map_shifts(shift, true ));
|
||||||
const emp = e.timesheet.employee;
|
for (const shift of vacation_shifts) rows.push(map_shifts(shift, false));
|
||||||
const week_number = this.computeWeekNumber(start_date, e.date);
|
|
||||||
|
|
||||||
|
for (const expense of expenses) {
|
||||||
|
const employee = expense.timesheet.employee;
|
||||||
|
const week = this.computeWeekNumber(start, expense.date);
|
||||||
rows.push({
|
rows.push({
|
||||||
company_code: emp.company_code,
|
company_code: employee.company_code,
|
||||||
external_payroll_id: emp.external_payroll_id,
|
external_payroll_id: employee.external_payroll_id,
|
||||||
full_name: `${emp.user.first_name} ${emp.user.last_name}`,
|
full_name: `${employee.first_name} ${ employee.last_name}`,
|
||||||
bank_code: e.bank_code.bank_code,
|
bank_code: expense.bank_code?.bank_code ?? '',
|
||||||
quantity_hours: undefined,
|
quantity_hours: undefined,
|
||||||
amount: Number(e.amount),
|
amount: Number(expense.amount),
|
||||||
week_number,
|
week_number: week,
|
||||||
pay_date: this.formatDate(end_date),
|
pay_date: this.formatDate(end),
|
||||||
holiday_date: undefined,
|
holiday_date: '',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//Leaves Mapping
|
//Final mapping and sorts
|
||||||
for(const l of leaves) {
|
rows.sort((a,b) => {
|
||||||
if(!l.bank_code) continue;
|
|
||||||
const emp = l.employee;
|
|
||||||
const start = l.start_date_time;
|
|
||||||
const end = l.end_date_time ?? start;
|
|
||||||
|
|
||||||
const week_number = this.computeWeekNumber(start_date, start);
|
|
||||||
const hours = this.computeHours(start, end);
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
company_code: emp.company_code,
|
|
||||||
external_payroll_id: emp.external_payroll_id,
|
|
||||||
full_name: `${emp.user.first_name} ${emp.user.last_name}`,
|
|
||||||
bank_code: l.bank_code.bank_code,
|
|
||||||
quantity_hours: hours,
|
|
||||||
amount: undefined,
|
|
||||||
week_number,
|
|
||||||
pay_date: this.formatDate(end_date),
|
|
||||||
holiday_date: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//Final Mapping and sorts
|
|
||||||
return rows.sort((a,b) => {
|
|
||||||
if(a.external_payroll_id !== b.external_payroll_id) {
|
if(a.external_payroll_id !== b.external_payroll_id) {
|
||||||
return a.external_payroll_id - b.external_payroll_id;
|
return a.external_payroll_id - b.external_payroll_id;
|
||||||
}
|
}
|
||||||
if(a.bank_code !== b.bank_code) {
|
const bk_code = String(a.bank_code).localeCompare(String(b.bank_code));
|
||||||
return a.bank_code.localeCompare(b.bank_code);
|
if(bk_code !== 0) return bk_code;
|
||||||
}
|
if(a.bank_code !== b.bank_code) return a.bank_code.localeCompare(b.bank_code);
|
||||||
return a.week_number - b.week_number;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveLeaveCodes(): { holiday_code: string; vacation_code: string; } {
|
||||||
|
const holiday_code = process.env.HOLIDAY_CODE?.trim();
|
||||||
|
if(!holiday_code) throw new BadRequestException('Missing Holiday bank code');
|
||||||
|
|
||||||
|
const vacation_code = process.env.VACATION_CODE?.trim();
|
||||||
|
if(!vacation_code) throw new BadRequestException('Missing Vacation bank code');
|
||||||
|
|
||||||
|
return { holiday_code, vacation_code};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
if (companies.targo) {
|
||||||
|
const code_no = parseInt(process.env.TARGO_NO ?? '', 10);
|
||||||
|
if(!Number.isFinite(code_no) || code_no <= 0) throw new BadRequestException('Invalid Targo code in env');
|
||||||
|
out.push(code_no);
|
||||||
|
}
|
||||||
|
if (companies.solucom) {
|
||||||
|
const code_no = parseInt(process.env.SOLUCOM_NO ?? '', 10);
|
||||||
|
if(!Number.isFinite(code_no) || code_no <= 0) throw new BadRequestException('Invalid Solucom code in env');
|
||||||
|
out.push(code_no);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
//csv builder and "mise en page"
|
||||||
generateCsv(rows: CsvRow[]): Buffer {
|
generateCsv(rows: CsvRow[]): Buffer {
|
||||||
const header = [
|
const header = [
|
||||||
'company_code',
|
'company_code',
|
||||||
|
|
@ -169,17 +260,22 @@ export class CsvExportService {
|
||||||
'holiday_date',
|
'holiday_date',
|
||||||
].join(',') + '\n';
|
].join(',') + '\n';
|
||||||
|
|
||||||
const body = rows.map(r => [
|
const body = rows.map(row => {
|
||||||
r.company_code,
|
const full_name = `${String(row.full_name).replace(/"/g, '""')}`;
|
||||||
r.external_payroll_id,
|
const quantity_hours = (typeof row.quantity_hours === 'number') ? row.quantity_hours.toFixed(2) : '';
|
||||||
`${r.full_name.replace(/"/g, '""')}"`,
|
const amount = (typeof row.amount === 'number') ? row.amount.toFixed(2) : '';
|
||||||
r.bank_code,
|
return [
|
||||||
r.quantity_hours?.toFixed(2) ?? '',
|
row.company_code,
|
||||||
r.week_number,
|
row.external_payroll_id,
|
||||||
r.pay_date,
|
full_name,
|
||||||
r.holiday_date ?? '',
|
row.bank_code,
|
||||||
].join(',')).join('\n');
|
quantity_hours,
|
||||||
|
amount,
|
||||||
|
row.week_number,
|
||||||
|
row.pay_date,
|
||||||
|
row.holiday_date ?? '',
|
||||||
|
].join(',');
|
||||||
|
}).join('\n');
|
||||||
return Buffer.from('\uFEFF' + header + body, 'utf8');
|
return Buffer.from('\uFEFF' + header + body, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,9 +286,13 @@ export class CsvExportService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeWeekNumber(start: Date, date: Date): number {
|
private computeWeekNumber(start: Date, date: Date): number {
|
||||||
const days = Math.floor((date.getTime() - start.getTime()) / (1000*60*60*24));
|
const dayMS = 86400000;
|
||||||
|
const days = Math.floor((this.toUTC(date).getTime() - this.toUTC(start).getTime())/ dayMS);
|
||||||
return Math.floor(days / 7 ) + 1;
|
return Math.floor(days / 7 ) + 1;
|
||||||
}
|
}
|
||||||
|
toUTC(date: Date) {
|
||||||
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
||||||
private formatDate(d:Date): string {
|
private formatDate(d:Date): string {
|
||||||
return d.toISOString().split('T')[0];
|
return d.toISOString().split('T')[0];
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,30 @@
|
||||||
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
import { Body, Controller, Post } from "@nestjs/common";
|
||||||
import { LeaveRequestsService } from "../services/leave-requests.service";
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
|
import { LeaveRequestsService } from "../services/leave-request.service";
|
||||||
import { LeaveRequests } from "@prisma/client";
|
import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
|
||||||
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto";
|
import { LeaveTypes } from "@prisma/client";
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
|
||||||
import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client';
|
|
||||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
|
||||||
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
|
|
||||||
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
|
|
||||||
|
|
||||||
@ApiTags('Leave Requests')
|
@ApiTags('Leave Requests')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
// @UseGuards()
|
// @UseGuards()
|
||||||
@Controller('leave-requests')
|
@Controller('leave-requests')
|
||||||
export class LeaveRequestController {
|
export class LeaveRequestController {
|
||||||
constructor(private readonly leaveRequetsService: LeaveRequestsService){}
|
constructor(private readonly leave_service: LeaveRequestsService){}
|
||||||
|
|
||||||
@Post()
|
@Post('upsert')
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
|
||||||
@ApiOperation({summary: 'Create leave request' })
|
const { action, leave_requests } = await this.leave_service.handle(dto);
|
||||||
@ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto })
|
return { action, leave_requests };
|
||||||
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
}q
|
||||||
create(@Body() dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
|
|
||||||
return this. leaveRequetsService.create(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
//TODO:
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
/*
|
||||||
@ApiOperation({summary: 'Find all leave request' })
|
@Get('archive')
|
||||||
@ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true })
|
findAllArchived(){...}
|
||||||
@ApiResponse({ status: 400, description: 'List of leave request not found' })
|
|
||||||
@UsePipes(new ValidationPipe({transform: true, whitelist: true}))
|
|
||||||
findAll(@Query() filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
|
|
||||||
return this.leaveRequetsService.findAll(filters);
|
|
||||||
}
|
|
||||||
//remove emp_id and use email
|
|
||||||
@Get(':id')
|
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
|
||||||
@ApiOperation({summary: 'Find leave request' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto })
|
|
||||||
@ApiResponse({ status: 400, description: 'Leave request not found' })
|
|
||||||
findOne(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
|
|
||||||
return this.leaveRequetsService.findOne(id);
|
|
||||||
}
|
|
||||||
//remove emp_id and use email
|
|
||||||
@Patch(':id')
|
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
|
||||||
@ApiOperation({summary: 'Update leave request' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Leave request updated',type: LeaveRequestViewDto })
|
|
||||||
@ApiResponse({ status: 400, description: 'Leave request not found' })
|
|
||||||
update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
|
|
||||||
return this.leaveRequetsService.update(id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
//remove emp_id and use email
|
@Get('archive/:id')
|
||||||
@Delete(':id')
|
findOneArchived(id){...}
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
*/
|
||||||
@ApiOperation({summary: 'Delete leave request' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Leave request deleted',type: CreateLeaveRequestsDto })
|
|
||||||
@ApiResponse({ status: 400, description: 'Leave request not found' })
|
|
||||||
remove(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
|
|
||||||
return this.leaveRequetsService.remove(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
//remove emp_id and use email
|
}
|
||||||
@Patch('approval/:id')
|
|
||||||
updateApproval( @Param('id', ParseIntPipe) id: number,
|
|
||||||
@Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise<LeaveRequestViewDto> {
|
|
||||||
const approvalStatus = is_approved ?
|
|
||||||
LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED;
|
|
||||||
return this.leaveRequetsService.update(id, { approval_status: approvalStatus });
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
|
||||||
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateLeaveRequestsDto {
|
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 7,
|
|
||||||
description: 'ID number of a leave-request code (link with bank-codes)',
|
|
||||||
})
|
|
||||||
@Type(()=> Number)
|
|
||||||
@IsInt()
|
|
||||||
bank_code_id: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'Sick or Vacation or Unpaid or Bereavement or Parental or Legal',
|
|
||||||
description: 'type of leave request for an accounting perception',
|
|
||||||
})
|
|
||||||
@IsEnum(LeaveTypes)
|
|
||||||
leave_type: LeaveTypes;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '22/06/2463',
|
|
||||||
description: 'Leave request`s start date',
|
|
||||||
})
|
|
||||||
@IsISO8601()
|
|
||||||
start_date_time:string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '25/03/3019',
|
|
||||||
description: 'Leave request`s end date',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsISO8601()
|
|
||||||
end_date_time?: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'My precious',
|
|
||||||
description: 'Leave request`s comment',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
comment: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'True or False or Pending or Denied or Cancelled or Escalated',
|
|
||||||
description: 'Leave request`s approval status',
|
|
||||||
})
|
|
||||||
@IsEnum(LeaveApprovalStatus)
|
|
||||||
@IsOptional()
|
|
||||||
approval_status?: LeaveApprovalStatus;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
|
||||||
export class LeaveRequestViewDto {
|
export class LeaveRequestViewDto {
|
||||||
id!: number;
|
id: number;
|
||||||
leave_type!: LeaveTypes;
|
leave_type!: LeaveTypes;
|
||||||
start_date_time!: string;
|
date!: string;
|
||||||
end_date_time!: string | null;
|
comment!: string;
|
||||||
comment!: string | null;
|
|
||||||
approval_status: LeaveApprovalStatus;
|
approval_status: LeaveApprovalStatus;
|
||||||
email!: string;
|
email!: string;
|
||||||
employee_full_name: string;
|
employee_full_name!: string;
|
||||||
days_requested?: number;
|
payable_hours?: number;
|
||||||
|
requested_hours?: number;
|
||||||
|
action?: 'create' | 'update' | 'delete';
|
||||||
}
|
}
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator";
|
|
||||||
|
|
||||||
export class SearchLeaveRequestsDto {
|
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(()=> Number)
|
|
||||||
@IsInt()
|
|
||||||
bank_code_id?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(LeaveApprovalStatus)
|
|
||||||
approval_status?: LeaveApprovalStatus
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
start_date?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
end_date?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(LeaveTypes)
|
|
||||||
leave_type?: LeaveTypes;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
import { PartialType } from "@nestjs/swagger";
|
|
||||||
import { CreateLeaveRequestsDto } from "./create-leave-request.dto";
|
|
||||||
|
|
||||||
export class UpdateLeaveRequestsDto extends PartialType(CreateLeaveRequestsDto){}
|
|
||||||
51
src/modules/leave-requests/dtos/upsert-leave-request.dto.ts
Normal file
51
src/modules/leave-requests/dtos/upsert-leave-request.dto.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsIn, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator";
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
import { LeaveRequestViewDto } from "./leave-request-view.dto";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
//sets wich function to call
|
||||||
|
export const UPSERT_ACTIONS = ['create', 'update', 'delete'] as const;
|
||||||
|
export type UpsertAction = (typeof UPSERT_ACTIONS)[number];
|
||||||
|
|
||||||
|
//sets wich types to use
|
||||||
|
export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[];
|
||||||
|
export type RequestTypes = (typeof REQUEST_TYPES)[number];
|
||||||
|
|
||||||
|
//filter requests by type and action
|
||||||
|
export interface UpsertResult {
|
||||||
|
action: UpsertAction;
|
||||||
|
leave_requests: LeaveRequestViewDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpsertLeaveRequestDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsISO8601({}, { each: true })
|
||||||
|
dates!: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LeaveTypes)
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsIn(UPSERT_ACTIONS)
|
||||||
|
action!: UpsertAction;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
@Max(24)
|
||||||
|
requested_hours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LeaveApprovalStatus)
|
||||||
|
approval_status?: LeaveApprovalStatus
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { LeaveRequestController } from "./controllers/leave-requests.controller";
|
import { LeaveRequestController } from "./controllers/leave-requests.controller";
|
||||||
import { LeaveRequestsService } from "./services/leave-requests.service";
|
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
||||||
|
import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
|
||||||
|
import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
|
||||||
|
import { LeaveRequestsService } from "./services/leave-request.service";
|
||||||
|
import { ShiftsModule } from "../shifts/shifts.module";
|
||||||
|
import { LeaveRequestsUtils } from "./utils/leave-request.util";
|
||||||
|
import { SharedModule } from "../shared/shared.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BusinessLogicsModule],
|
imports: [BusinessLogicsModule, ShiftsModule, SharedModule],
|
||||||
controllers: [LeaveRequestController],
|
controllers: [LeaveRequestController],
|
||||||
providers: [LeaveRequestsService],
|
providers: [
|
||||||
exports: [LeaveRequestsService],
|
VacationLeaveRequestsService,
|
||||||
|
SickLeaveRequestsService,
|
||||||
|
HolidayLeaveRequestsService,
|
||||||
|
LeaveRequestsService,
|
||||||
|
PrismaService,
|
||||||
|
LeaveRequestsUtils,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
LeaveRequestsService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
export class LeaveRequestsModule {}
|
export class LeaveRequestsModule {}
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select";
|
import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select";
|
||||||
|
|
||||||
const toISO = (date: Date | null): string | null => (date ? date.toISOString().slice(0,10): null);
|
const toNum = (value?: Prisma.Decimal | null) => value ? Number(value) : undefined;
|
||||||
|
|
||||||
export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto {
|
export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto {
|
||||||
|
const isoDate = row.date?.toISOString().slice(0, 10);
|
||||||
|
if (!isoDate) {
|
||||||
|
throw new Error(`Leave request #${row.id} has no date set.`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
leave_type: row.leave_type,
|
leave_type: row.leave_type,
|
||||||
start_date_time: toISO(row.start_date_time)!,
|
date: isoDate,
|
||||||
end_date_time: toISO(row.end_date_time),
|
payable_hours: toNum(row.payable_hours),
|
||||||
|
requested_hours: toNum(row.requested_hours),
|
||||||
comment: row.comment,
|
comment: row.comment,
|
||||||
approval_status: row.approval_status,
|
approval_status: row.approval_status,
|
||||||
email,
|
email,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
import { LeaveRequestRow } from "../utils/leave-requests.select";
|
import { LeaveRequestRow } from "../utils/leave-requests.select";
|
||||||
|
|
||||||
function toISODateString(date:Date | null): string | null {
|
const toNum = (value?: Prisma.Decimal | null) =>
|
||||||
return date ? date.toISOString().slice(0,10) : null;
|
value !== null && value !== undefined ? Number(value) : undefined;
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto {
|
export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto {
|
||||||
|
const iso_date = row.date?.toISOString().slice(0, 10);
|
||||||
|
if (!iso_date) throw new Error(`Leave request #${row.id} has no date set.`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
leave_type: row.leave_type,
|
leave_type: row.leave_type,
|
||||||
start_date_time: toISODateString(row.start_date_time)!,
|
date: iso_date,
|
||||||
end_date_time: toISODateString(row.end_date_time),
|
payable_hours: toNum(row.payable_hours),
|
||||||
|
requested_hours: toNum(row.requested_hours),
|
||||||
comment: row.comment,
|
comment: row.comment,
|
||||||
approval_status: row.approval_status,
|
approval_status: row.approval_status,
|
||||||
email: row.employee.user.email,
|
email: row.employee.user.email,
|
||||||
employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`
|
employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
|
||||||
|
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
|
||||||
|
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
|
||||||
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
|
import { mapRowToView } from '../mappers/leave-requests.mapper';
|
||||||
|
import { leaveRequestsSelect } from '../utils/leave-requests.select';
|
||||||
|
import { LeaveRequestsUtils} from '../utils/leave-request.util';
|
||||||
|
import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers';
|
||||||
|
import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils';
|
||||||
|
import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
|
||||||
|
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HolidayLeaveRequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly holidayService: HolidayService,
|
||||||
|
private readonly leaveUtils: LeaveRequestsUtils,
|
||||||
|
private readonly emailResolver: EmailToIdResolver,
|
||||||
|
private readonly typeResolver: BankCodesResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
const bank_code = await this.typeResolver.findByType(LeaveTypes.HOLIDAY);
|
||||||
|
if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
|
||||||
|
|
||||||
|
const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
|
for (const iso_date of dates) {
|
||||||
|
const date = toDateOnly(iso_date);
|
||||||
|
|
||||||
|
const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
|
where: {
|
||||||
|
leave_per_employee_date: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: LeaveTypes.HOLIDAY,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
|
||||||
|
const row = await this.prisma.leaveRequests.create({
|
||||||
|
data: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
leave_type: LeaveTypes.HOLIDAY,
|
||||||
|
date,
|
||||||
|
comment: dto.comment ?? '',
|
||||||
|
requested_hours: dto.requested_hours ?? 8,
|
||||||
|
payable_hours: payable,
|
||||||
|
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
|
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
created.push({ ...mapRowToView(row), action: 'create' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: 'create', leave_requests: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
248
src/modules/leave-requests/services/leave-request.service.ts
Normal file
248
src/modules/leave-requests/services/leave-request.service.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
|
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
|
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
|
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
|
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
|
import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
|
||||||
|
import { SickLeaveRequestsService } from "./sick-leave-requests.service";
|
||||||
|
import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
|
||||||
|
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
|
||||||
|
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
||||||
|
import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
||||||
|
import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers";
|
||||||
|
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
|
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LeaveRequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly holidayLeaveService: HolidayLeaveRequestsService,
|
||||||
|
private readonly holidayService: HolidayService,
|
||||||
|
private readonly sickLogic: SickLeaveService,
|
||||||
|
private readonly sickLeaveService: SickLeaveRequestsService,
|
||||||
|
private readonly vacationLeaveService: VacationLeaveRequestsService,
|
||||||
|
private readonly vacationLogic: VacationService,
|
||||||
|
private readonly leaveUtils: LeaveRequestsUtils,
|
||||||
|
private readonly emailResolver: EmailToIdResolver,
|
||||||
|
private readonly typeResolver: BankCodesResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
//handle distribution to the right service according to the selected type and action
|
||||||
|
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
switch (dto.type) {
|
||||||
|
case LeaveTypes.HOLIDAY:
|
||||||
|
if( dto.action === 'create'){
|
||||||
|
return this.holidayLeaveService.create(dto);
|
||||||
|
} else if (dto.action === 'update') {
|
||||||
|
return this.update(dto, LeaveTypes.HOLIDAY);
|
||||||
|
} else if (dto.action === 'delete'){
|
||||||
|
return this.delete(dto, LeaveTypes.HOLIDAY);
|
||||||
|
}
|
||||||
|
case LeaveTypes.VACATION:
|
||||||
|
if( dto.action === 'create'){
|
||||||
|
return this.vacationLeaveService.create(dto);
|
||||||
|
} else if (dto.action === 'update') {
|
||||||
|
return this.update(dto, LeaveTypes.VACATION);
|
||||||
|
} else if (dto.action === 'delete'){
|
||||||
|
return this.delete(dto, LeaveTypes.VACATION);
|
||||||
|
}
|
||||||
|
case LeaveTypes.SICK:
|
||||||
|
if( dto.action === 'create'){
|
||||||
|
return this.sickLeaveService.create(dto);
|
||||||
|
} else if (dto.action === 'update') {
|
||||||
|
return this.update(dto, LeaveTypes.SICK);
|
||||||
|
} else if (dto.action === 'delete'){
|
||||||
|
return this.delete(dto, LeaveTypes.SICK);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
|
|
||||||
|
const rows = await this.prisma.leaveRequests.findMany({
|
||||||
|
where: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: type,
|
||||||
|
date: { in: dates.map((d) => toDateOnly(d)) },
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length !== dates.length) {
|
||||||
|
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
|
||||||
|
throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
|
const iso = toISODateKey(row.date);
|
||||||
|
await this.leaveUtils.removeShift(email, employee_id, iso, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.leaveRequests.deleteMany({
|
||||||
|
where: { id: { in: rows.map((row) => row.id) } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
|
||||||
|
return { action: "delete", leave_requests: deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
const bank_code = await this.typeResolver.findByType(type);
|
||||||
|
if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
||||||
|
const modifier = Number(bank_code.modifier ?? 1);
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
if (!dates.length) {
|
||||||
|
throw new BadRequestException("Dates array must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
dates.map(async (iso_date) => {
|
||||||
|
const date = toDateOnly(iso_date);
|
||||||
|
const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
|
where: {
|
||||||
|
leave_per_employee_date: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: type,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`);
|
||||||
|
return { iso_date, date, existing };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
|
if (type === LeaveTypes.SICK) {
|
||||||
|
const firstExisting = entries[0].existing;
|
||||||
|
const fallbackRequested =
|
||||||
|
firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
|
||||||
|
? Number(firstExisting.requested_hours)
|
||||||
|
: 8;
|
||||||
|
const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
|
||||||
|
const reference_date = entries.reduce(
|
||||||
|
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||||
|
entries[0].date,
|
||||||
|
);
|
||||||
|
const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
|
||||||
|
employee_id,
|
||||||
|
reference_date,
|
||||||
|
entries.length,
|
||||||
|
requested_hours_per_day,
|
||||||
|
modifier,
|
||||||
|
);
|
||||||
|
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
|
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
|
for (const { iso_date, existing } of entries) {
|
||||||
|
const previous_status = existing.approval_status;
|
||||||
|
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
|
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
|
remaining_payable_hours = roundToQuarterHour(
|
||||||
|
Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = await this.prisma.leaveRequests.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
comment: dto.comment ?? existing.comment,
|
||||||
|
requested_hours: requested_hours_per_day,
|
||||||
|
payable_hours: payable_rounded,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
approval_status: dto.approval_status ?? existing.approval_status,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
|
||||||
|
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
|
||||||
|
if (!was_approved && is_approved) {
|
||||||
|
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
|
} else if (was_approved && !is_approved) {
|
||||||
|
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
|
||||||
|
} else if (was_approved && is_approved) {
|
||||||
|
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
|
}
|
||||||
|
updated.push({ ...mapRowToView(row), action: "update" });
|
||||||
|
}
|
||||||
|
return { action: "update", leave_requests: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { iso_date, date, existing } of entries) {
|
||||||
|
const previous_status = existing.approval_status;
|
||||||
|
const fallbackRequested =
|
||||||
|
existing.requested_hours !== null && existing.requested_hours !== undefined
|
||||||
|
? Number(existing.requested_hours)
|
||||||
|
: 8;
|
||||||
|
const requested_hours = dto.requested_hours ?? fallbackRequested;
|
||||||
|
|
||||||
|
let payable: number;
|
||||||
|
switch (type) {
|
||||||
|
case LeaveTypes.HOLIDAY:
|
||||||
|
payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
|
||||||
|
break;
|
||||||
|
case LeaveTypes.VACATION: {
|
||||||
|
const days_requested = requested_hours / 8;
|
||||||
|
payable = await this.vacationLogic.calculateVacationPay(
|
||||||
|
employee_id,
|
||||||
|
date,
|
||||||
|
Math.max(0, days_requested),
|
||||||
|
modifier,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
payable = existing.payable_hours !== null && existing.payable_hours !== undefined
|
||||||
|
? Number(existing.payable_hours)
|
||||||
|
: requested_hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.prisma.leaveRequests.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
requested_hours,
|
||||||
|
comment: dto.comment ?? existing.comment,
|
||||||
|
payable_hours: payable,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
approval_status: dto.approval_status ?? existing.approval_status,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
|
||||||
|
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
|
||||||
|
if (!was_approved && is_approved) {
|
||||||
|
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
|
} else if (was_approved && !is_approved) {
|
||||||
|
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
|
||||||
|
} else if (was_approved && is_approved) {
|
||||||
|
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
|
}
|
||||||
|
updated.push({ ...mapRowToView(row), action: "update" });
|
||||||
|
}
|
||||||
|
return { action: "update", leave_requests: updated };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
|
||||||
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
|
|
||||||
import { LeaveRequests, LeaveRequestsArchive } from "@prisma/client";
|
|
||||||
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto";
|
|
||||||
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
|
|
||||||
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
|
||||||
import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
|
||||||
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
|
|
||||||
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
|
|
||||||
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
|
|
||||||
import { LeaveRequestRow, leaveRequestsSelect } from "../utils/leave-requests.select";
|
|
||||||
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
|
||||||
import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller";
|
|
||||||
import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select";
|
|
||||||
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
|
|
||||||
import { mapArchiveRowToViewWithDays, mapRowToViewWithDays } from "../utils/leave-request.transform";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LeaveRequestsService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly holidayService: HolidayService,
|
|
||||||
private readonly vacationService: VacationService,
|
|
||||||
private readonly sickLeaveService: SickLeaveService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
//function to avoid using employee_id as identifier in the frontend.
|
|
||||||
private async resolveEmployeeIdByEmail(email: string): Promise<number> {
|
|
||||||
const employee = await this.prisma.employees.findFirst({
|
|
||||||
where: { user: { email} },
|
|
||||||
select: { id:true },
|
|
||||||
});
|
|
||||||
if(!employee) throw new NotFoundException(`Employee with email ${email} not found`);
|
|
||||||
return employee.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
//create a leave-request without the use of employee_id
|
|
||||||
async create(dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
|
|
||||||
const employee_id = await this.resolveEmployeeIdByEmail(dto.email);
|
|
||||||
const row: LeaveRequestRow = await this.prisma.leaveRequests.create({
|
|
||||||
data: {
|
|
||||||
employee_id,
|
|
||||||
bank_code_id: dto.bank_code_id,
|
|
||||||
leave_type: dto.leave_type,
|
|
||||||
start_date_time: new Date(dto.start_date_time),
|
|
||||||
end_date_time: dto.end_date_time ? new Date(dto.end_date_time) : null,
|
|
||||||
comment: dto.comment,
|
|
||||||
approval_status: dto.approval_status ?? undefined,
|
|
||||||
},
|
|
||||||
select: leaveRequestsSelect,
|
|
||||||
});
|
|
||||||
return mapRowToViewWithDays(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
//fetches all leave-requests using email
|
|
||||||
async findAll(filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
|
|
||||||
const {start_date, end_date,email, leave_type, approval_status, bank_code_id } = filters;
|
|
||||||
const where: any = {};
|
|
||||||
|
|
||||||
if (start_date) where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) };
|
|
||||||
if (end_date) where.end_date_time = { ...(where.end_date_time ?? {}), lte: new Date(end_date) };
|
|
||||||
if (email) where.employee = { user: { email } };
|
|
||||||
if (leave_type) where.leave_type = leave_type;
|
|
||||||
if (approval_status) where.approval_status = approval_status;
|
|
||||||
if (typeof bank_code_id === 'number') where.bank_code_id = bank_code_id;
|
|
||||||
|
|
||||||
const rows= await this.prisma.leaveRequests.findMany({
|
|
||||||
where,
|
|
||||||
select: leaveRequestsSelect,
|
|
||||||
orderBy: { start_date_time: 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows.map(mapRowToViewWithDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
//fetch 1 leave-request using email
|
|
||||||
async findOne(id:number): Promise<LeaveRequestViewDto> {
|
|
||||||
const row: LeaveRequestRow | null = await this.prisma.leaveRequests.findUnique({
|
|
||||||
where: { id },
|
|
||||||
select: leaveRequestsSelect,
|
|
||||||
});
|
|
||||||
if(!row) throw new NotFoundException(`Leave Request #${id} not found`);
|
|
||||||
return mapRowToViewWithDays(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
//updates 1 leave-request using email
|
|
||||||
async update(id: number, dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
|
|
||||||
await this.findOne(id);
|
|
||||||
const data: Record<string, any> = {};
|
|
||||||
|
|
||||||
if(dto.email !== undefined) data.employee_id = await this.resolveEmployeeIdByEmail(dto.email);
|
|
||||||
if(dto.leave_type !== undefined) data.bank_code_id = dto.bank_code_id;
|
|
||||||
if(dto.start_date_time !== undefined) data.start_date_time = new Date(dto.start_date_time);
|
|
||||||
if(dto.end_date_time !== undefined) data.end_date_time = new Date(dto.end_date_time);
|
|
||||||
if(dto.comment !== undefined) data.comment = dto.comment;
|
|
||||||
if(dto.approval_status !== undefined) data.approval_status = dto.approval_status;
|
|
||||||
|
|
||||||
const row: LeaveRequestRow = await this.prisma.leaveRequests.update({
|
|
||||||
where: { id },
|
|
||||||
data,
|
|
||||||
select: leaveRequestsSelect,
|
|
||||||
});
|
|
||||||
return mapRowToViewWithDays(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
//removes 1 leave-request using email
|
|
||||||
async remove(id:number): Promise<LeaveRequestViewDto> {
|
|
||||||
await this.findOne(id);
|
|
||||||
const row: LeaveRequestRow = await this.prisma.leaveRequests.delete({
|
|
||||||
where: { id },
|
|
||||||
select: leaveRequestsSelect,
|
|
||||||
});
|
|
||||||
return mapRowToViewWithDays(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
//archivation functions ******************************************************
|
|
||||||
|
|
||||||
async archiveExpired(): Promise<void> {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
await this.prisma.$transaction(async transaction => {
|
|
||||||
//fetches expired leave requests
|
|
||||||
const expired = await transaction.leaveRequests.findMany({
|
|
||||||
where: { end_date_time: { lt: now } },
|
|
||||||
});
|
|
||||||
if(expired.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
//copy unto archive table
|
|
||||||
await transaction.leaveRequestsArchive.createMany({
|
|
||||||
data: expired.map(request => ({
|
|
||||||
leave_request_id: request.id,
|
|
||||||
employee_id: request.employee_id,
|
|
||||||
leave_type: request.leave_type,
|
|
||||||
start_date_time: request.start_date_time,
|
|
||||||
end_date_time: request.end_date_time,
|
|
||||||
comment: request.comment,
|
|
||||||
approval_status: request.approval_status,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
//delete from leave_requests table
|
|
||||||
await transaction.leaveRequests.deleteMany({
|
|
||||||
where: { id: { in: expired.map(request => request.id ) } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//fetches all archived leave-requests
|
|
||||||
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
|
|
||||||
return this.prisma.leaveRequestsArchive.findMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
//remove emp_id and use email
|
|
||||||
//fetches an archived employee
|
|
||||||
async findOneArchived(id: number): Promise<LeaveRequestViewDto> {
|
|
||||||
const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({
|
|
||||||
where: { id },
|
|
||||||
select: leaveRequestsArchiveSelect,
|
|
||||||
});
|
|
||||||
if(!row) throw new NotFoundException(`Archived Leave Request #${id} not found`);
|
|
||||||
|
|
||||||
const emp = await this.prisma.employees.findUnique({
|
|
||||||
where: { id: row.employee_id },
|
|
||||||
select: { user: {select: { email:true,
|
|
||||||
first_name: true,
|
|
||||||
last_name: true,
|
|
||||||
}}},
|
|
||||||
});
|
|
||||||
const email = emp?.user.email ?? "";
|
|
||||||
const full_name = emp ? `${emp.user.first_name} ${emp.user.last_name}` : "";
|
|
||||||
|
|
||||||
return mapArchiveRowToViewWithDays(row, email, full_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
|
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
|
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
||||||
|
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
|
import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
||||||
|
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
|
||||||
|
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
|
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SickLeaveRequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sickService: SickLeaveService,
|
||||||
|
private readonly leaveUtils: LeaveRequestsUtils,
|
||||||
|
private readonly emailResolver: EmailToIdResolver,
|
||||||
|
private readonly typeResolver: BankCodesResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK);
|
||||||
|
if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
||||||
|
|
||||||
|
const modifier = bank_code.modifier ?? 1;
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
|
const requested_hours_per_day = dto.requested_hours ?? 8;
|
||||||
|
|
||||||
|
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
|
||||||
|
const reference_date = entries.reduce(
|
||||||
|
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||||
|
entries[0].date,
|
||||||
|
);
|
||||||
|
const total_payable_hours = await this.sickService.calculateSickLeavePay(
|
||||||
|
employee_id,
|
||||||
|
reference_date,
|
||||||
|
entries.length,
|
||||||
|
requested_hours_per_day,
|
||||||
|
modifier,
|
||||||
|
);
|
||||||
|
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
|
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
|
const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
|
for (const { iso, date } of entries) {
|
||||||
|
const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
|
where: {
|
||||||
|
leave_per_employee_date: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: LeaveTypes.SICK,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException(`Sick request already exists for ${iso}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
|
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
|
remaining_payable_hours = roundToQuarterHour(
|
||||||
|
Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = await this.prisma.leaveRequests.create({
|
||||||
|
data: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
leave_type: LeaveTypes.SICK,
|
||||||
|
comment: dto.comment ?? "",
|
||||||
|
requested_hours: requested_hours_per_day,
|
||||||
|
payable_hours: payable_rounded,
|
||||||
|
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
|
await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
created.push({ ...mapRowToView(row), action: "create" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "create", leave_requests: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
|
||||||
|
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
|
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
|
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
|
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
|
import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
||||||
|
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
|
||||||
|
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
|
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VacationLeaveRequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly vacationService: VacationService,
|
||||||
|
private readonly leaveUtils: LeaveRequestsUtils,
|
||||||
|
private readonly emailResolver: EmailToIdResolver,
|
||||||
|
private readonly typeResolver: BankCodesResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION);
|
||||||
|
if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
||||||
|
|
||||||
|
const modifier = bank_code.modifier ?? 1;
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
const requested_hours_per_day = dto.requested_hours ?? 8;
|
||||||
|
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
|
|
||||||
|
const entries = dates
|
||||||
|
.map((iso) => ({ iso, date: toDateOnly(iso) }))
|
||||||
|
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
|
const start_date = entries[0].date;
|
||||||
|
const total_payable_hours = await this.vacationService.calculateVacationPay(
|
||||||
|
employee_id,
|
||||||
|
start_date,
|
||||||
|
entries.length,
|
||||||
|
modifier,
|
||||||
|
);
|
||||||
|
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
|
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
|
const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
|
for (const { iso, date } of entries) {
|
||||||
|
const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
|
where: {
|
||||||
|
leave_per_employee_date: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: LeaveTypes.VACATION,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
|
||||||
|
|
||||||
|
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
|
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
|
remaining_payable_hours = roundToQuarterHour(
|
||||||
|
Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = await this.prisma.leaveRequests.create({
|
||||||
|
data: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
payable_hours: payable_rounded,
|
||||||
|
requested_hours: requested_hours_per_day,
|
||||||
|
leave_type: LeaveTypes.VACATION,
|
||||||
|
comment: dto.comment ?? "",
|
||||||
|
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
|
await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
|
||||||
|
}
|
||||||
|
created.push({ ...mapRowToView(row), action: "create" });
|
||||||
|
}
|
||||||
|
return { action: "create", leave_requests: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,19 @@
|
||||||
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
|
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
|
||||||
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
|
import { mapArchiveRowToView } from '../mappers/leave-requests-archive.mapper';
|
||||||
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
import { mapRowToView } from '../mappers/leave-requests.mapper';
|
||||||
import { LeaveRequestArchiveRow } from "./leave-requests-archive.select";
|
import { LeaveRequestArchiveRow } from './leave-requests-archive.select';
|
||||||
import { LeaveRequestRow } from "./leave-requests.select";
|
import { LeaveRequestRow } from './leave-requests.select';
|
||||||
|
|
||||||
function toUTCDateOnly(date: Date): Date {
|
/** Active (table leave_requests) : proxy to base mapper */
|
||||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
||||||
}
|
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
|
||||||
function computeDaysRequested(start_date: Date, end_date?: Date | null): number {
|
|
||||||
const start = toUTCDateOnly(start_date);
|
|
||||||
const end = toUTCDateOnly(end_date ?? start_date);
|
|
||||||
const diff = Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
|
|
||||||
return Math.max(1, diff);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Active (table leave_requests) : map + days_requested */
|
|
||||||
export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto {
|
export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto {
|
||||||
const view = mapRowToView(row);
|
return mapRowToView(row);
|
||||||
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
|
|
||||||
return view;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Archive (table leave_requests_archive) : map + days_requested */
|
/** Archive (table leave_requests_archive) : proxy to base mapper */
|
||||||
export function mapArchiveRowToViewWithDays(row: LeaveRequestArchiveRow, email: string, employee_full_name?: string):
|
export function mapArchiveRowToViewWithDays(
|
||||||
LeaveRequestViewDto {
|
row: LeaveRequestArchiveRow,
|
||||||
const view = mapArchiveRowToView(row, email, employee_full_name!);
|
email: string,
|
||||||
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
|
employee_full_name?: string,
|
||||||
return view;
|
): LeaveRequestViewDto {
|
||||||
|
return mapArchiveRowToView(row, email, employee_full_name!);
|
||||||
}
|
}
|
||||||
102
src/modules/leave-requests/utils/leave-request.util.ts
Normal file
102
src/modules/leave-requests/utils/leave-request.util.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers";
|
||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { LeaveTypes } from "@prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LeaveRequestsUtils {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly shiftsCommand: ShiftsCommandService,
|
||||||
|
){}
|
||||||
|
|
||||||
|
async syncShift(
|
||||||
|
email: string,
|
||||||
|
employee_id: number,
|
||||||
|
date: string,
|
||||||
|
hours: number,
|
||||||
|
type: LeaveTypes,
|
||||||
|
comment?: string,
|
||||||
|
) {
|
||||||
|
if (hours <= 0) return;
|
||||||
|
|
||||||
|
const duration_minutes = Math.round(hours * 60);
|
||||||
|
if (duration_minutes > 8 * 60) {
|
||||||
|
throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
|
||||||
|
}
|
||||||
|
const date_only = toDateOnly(date);
|
||||||
|
const yyyy_mm_dd = toStringFromDate(date_only);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const start_minutes = 8 * 60;
|
||||||
|
const end_minutes = start_minutes + duration_minutes;
|
||||||
|
const toHHmm = (total: number) =>
|
||||||
|
`${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const existing = await this.prisma.shifts.findFirst({
|
||||||
|
where: {
|
||||||
|
date: date_only,
|
||||||
|
bank_code: { type },
|
||||||
|
timesheet: { employee_id: employee_id },
|
||||||
|
},
|
||||||
|
include: { bank_code: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.shiftsCommand.upsertShiftsByDate(email, {
|
||||||
|
old_shift: existing
|
||||||
|
? {
|
||||||
|
date: yyyy_mm_dd,
|
||||||
|
start_time: existing.start_time.toISOString().slice(11, 16),
|
||||||
|
end_time: existing.end_time.toISOString().slice(11, 16),
|
||||||
|
type: existing.bank_code?.type ?? type,
|
||||||
|
is_remote: existing.is_remote,
|
||||||
|
is_approved:existing.is_approved,
|
||||||
|
comment: existing.comment ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
new_shift: {
|
||||||
|
date: yyyy_mm_dd,
|
||||||
|
start_time: toHHmm(start_minutes),
|
||||||
|
end_time: toHHmm(end_minutes),
|
||||||
|
is_remote: existing?.is_remote ?? false,
|
||||||
|
is_approved:existing?.is_approved ?? false,
|
||||||
|
comment: comment ?? existing?.comment ?? "",
|
||||||
|
type: type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeShift(
|
||||||
|
email: string,
|
||||||
|
employee_id: number,
|
||||||
|
iso_date: string,
|
||||||
|
type: LeaveTypes,
|
||||||
|
) {
|
||||||
|
const date_only = toDateOnly(iso_date);
|
||||||
|
const yyyy_mm_dd = toStringFromDate(date_only);
|
||||||
|
const existing = await this.prisma.shifts.findFirst({
|
||||||
|
where: {
|
||||||
|
date: date_only,
|
||||||
|
bank_code: { type },
|
||||||
|
timesheet: { employee_id: employee_id },
|
||||||
|
},
|
||||||
|
include: { bank_code: true },
|
||||||
|
});
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
await this.shiftsCommand.upsertShiftsByDate(email, {
|
||||||
|
old_shift: {
|
||||||
|
date: yyyy_mm_dd,
|
||||||
|
start_time: hhmmFromLocal(existing.start_time),
|
||||||
|
end_time: hhmmFromLocal(existing.end_time),
|
||||||
|
type: existing.bank_code?.type ?? type,
|
||||||
|
is_remote: existing.is_remote,
|
||||||
|
is_approved:existing.is_approved,
|
||||||
|
comment: existing.comment ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,11 @@ export const leaveRequestsArchiveSelect = {
|
||||||
archived_at: true,
|
archived_at: true,
|
||||||
employee_id: true,
|
employee_id: true,
|
||||||
leave_type: true,
|
leave_type: true,
|
||||||
start_date_time: true,
|
date: true,
|
||||||
end_date_time: true,
|
payable_hours: true,
|
||||||
|
requested_hours: true,
|
||||||
comment: true,
|
comment: true,
|
||||||
approval_status: true,
|
approval_status: true,
|
||||||
|
|
||||||
} satisfies Prisma.LeaveRequestsArchiveSelect;
|
} satisfies Prisma.LeaveRequestsArchiveSelect;
|
||||||
|
|
||||||
export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>;
|
export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>;
|
||||||
|
|
@ -5,8 +5,9 @@ export const leaveRequestsSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
bank_code_id: true,
|
bank_code_id: true,
|
||||||
leave_type: true,
|
leave_type: true,
|
||||||
start_date_time: true,
|
date: true,
|
||||||
end_date_time: true,
|
payable_hours: true,
|
||||||
|
requested_hours: true,
|
||||||
comment: true,
|
comment: true,
|
||||||
approval_status: true,
|
approval_status: true,
|
||||||
employee: { select: {
|
employee: { select: {
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,6 @@ export class PayPeriodsController {
|
||||||
private readonly commandService: PayPeriodsCommandService,
|
private readonly commandService: PayPeriodsCommandService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
|
||||||
@ApiOperation({ summary: 'Find all pay period' })
|
|
||||||
@ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true })
|
|
||||||
async findAll(): Promise<PayPeriodDto[]> {
|
|
||||||
return this.queryService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('current-and-all')
|
@Get('current-and-all')
|
||||||
@ApiOperation({summary: 'Return current pay period and the full list'})
|
@ApiOperation({summary: 'Return current pay period and the full list'})
|
||||||
@ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'})
|
@ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'})
|
||||||
|
|
@ -95,4 +88,16 @@ export class PayPeriodsController {
|
||||||
): Promise<PayPeriodOverviewDto> {
|
): Promise<PayPeriodOverviewDto> {
|
||||||
return this.queryService.getOverviewByYearPeriod(year, period_no);
|
return this.queryService.getOverviewByYearPeriod(year, period_no);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
// Deprecated or unused methods
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
|
||||||
|
// @Get()
|
||||||
|
// @ApiOperation({ summary: 'Find all pay period' })
|
||||||
|
// @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true })
|
||||||
|
// async findAll(): Promise<PayPeriodDto[]> {
|
||||||
|
// return this.queryService.findAll();
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export class EmployeePeriodOverviewDto {
|
||||||
// employee_id: number;
|
// employee_id: number;
|
||||||
|
|
||||||
|
|
||||||
email:string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Alex Dupont',
|
example: 'Alex Dupont',
|
||||||
|
|
@ -21,15 +21,23 @@ export class EmployeePeriodOverviewDto {
|
||||||
@ApiProperty({ example: 40, description: 'pay-period`s regular hours' })
|
@ApiProperty({ example: 40, description: 'pay-period`s regular hours' })
|
||||||
regular_hours: number;
|
regular_hours: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 0, description: 'pay-period`s evening hours' })
|
@ApiProperty({ example: 0, description: 'pay-period`s other hours' })
|
||||||
|
other_hours: {
|
||||||
evening_hours: number;
|
evening_hours: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 0, description: 'pay-period`s emergency hours' })
|
|
||||||
emergency_hours: number;
|
emergency_hours: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 2, description: 'pay-period`s overtime hours' })
|
|
||||||
overtime_hours: number;
|
overtime_hours: number;
|
||||||
|
|
||||||
|
sick_hours: number;
|
||||||
|
|
||||||
|
holiday_hours: number;
|
||||||
|
|
||||||
|
vacation_hours: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
total_hours: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' })
|
@ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' })
|
||||||
expenses: number;
|
expenses: number;
|
||||||
|
|
||||||
|
|
@ -41,4 +49,6 @@ export class EmployeePeriodOverviewDto {
|
||||||
description: 'Tous les timesheets de la période sont approuvés pour cet employé',
|
description: 'Tous les timesheets de la période sont approuvés pour cet employé',
|
||||||
})
|
})
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
|
|
||||||
|
is_remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,24 @@ import { TimesheetsModule } from "../timesheets/timesheets.module";
|
||||||
import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service";
|
import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service";
|
||||||
import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
|
import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
|
||||||
import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
|
import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
|
||||||
|
import { SharedModule } from "../shared/shared.module";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { BusinessLogicsModule } from "../business-logics/business-logics.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, TimesheetsModule],
|
imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
|
||||||
providers: [
|
providers: [
|
||||||
PayPeriodsQueryService,
|
PayPeriodsQueryService,
|
||||||
PayPeriodsCommandService,
|
PayPeriodsCommandService,
|
||||||
TimesheetsCommandService,
|
TimesheetsCommandService,
|
||||||
ExpensesCommandService,
|
ExpensesCommandService,
|
||||||
ShiftsCommandService,
|
ShiftsCommandService,
|
||||||
|
PrismaService,
|
||||||
],
|
],
|
||||||
controllers: [PayPeriodsController],
|
controllers: [PayPeriodsController],
|
||||||
exports: [
|
exports: [
|
||||||
PayPeriodsQueryService,
|
PayPeriodsQueryService,
|
||||||
PayPeriodsCommandService,
|
PayPeriodsCommandService,
|
||||||
PayPeriodsQueryService,
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,38 +68,4 @@ export class PayPeriodsCommandService {
|
||||||
});
|
});
|
||||||
return {updated};
|
return {updated};
|
||||||
}
|
}
|
||||||
|
|
||||||
//function to approve a single pay-period of a single employee (deprecated)
|
|
||||||
// async approvalPayPeriod(pay_year: number , period_no: number): Promise<void> {
|
|
||||||
// const period = await this.prisma.payPeriods.findFirst({
|
|
||||||
// where: { pay_year, pay_period_no: period_no},
|
|
||||||
// });
|
|
||||||
// if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`);
|
|
||||||
|
|
||||||
// //fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense
|
|
||||||
// const timesheet_ist = await this.prisma.timesheets.findMany({
|
|
||||||
// where: {
|
|
||||||
// OR: [
|
|
||||||
// { shift: {some: { date: { gte: period.period_start,
|
|
||||||
// lte: period.period_end,
|
|
||||||
// },
|
|
||||||
// }},
|
|
||||||
// },
|
|
||||||
// { expense: { some: { date: { gte: period.period_start,
|
|
||||||
// lte: period.period_end,
|
|
||||||
// },
|
|
||||||
// }},
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// select: { id: true },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// //approval of both timesheet (cascading to the approval of related shifts and expenses)
|
|
||||||
// await this.prisma.$transaction(async (transaction)=> {
|
|
||||||
// for(const {id} of timesheet_ist) {
|
|
||||||
// await this.timesheets_approval.updateApprovalWithTransaction(transaction,id, true);
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PayPeriodsQueryService {
|
export class PayPeriodsQueryService {
|
||||||
constructor( private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) { }
|
||||||
|
|
||||||
async getOverview(pay_period_no: number): Promise<PayPeriodOverviewDto> {
|
async getOverview(pay_period_no: number): Promise<PayPeriodOverviewDto> {
|
||||||
const period = await this.prisma.payPeriods.findFirst({
|
const period = await this.prisma.payPeriods.findFirst({
|
||||||
|
|
@ -20,11 +20,11 @@ export class PayPeriodsQueryService {
|
||||||
|
|
||||||
return this.buildOverview({
|
return this.buildOverview({
|
||||||
period_start: period.period_start,
|
period_start: period.period_start,
|
||||||
period_end : period.period_end,
|
period_end: period.period_end,
|
||||||
payday : period.payday,
|
payday: period.payday,
|
||||||
period_no : period.pay_period_no,
|
period_no: period.pay_period_no,
|
||||||
pay_year : period.pay_year,
|
pay_year: period.pay_year,
|
||||||
label : period.label,
|
label: period.label,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,14 +32,15 @@ export class PayPeriodsQueryService {
|
||||||
const period = computePeriod(pay_year, period_no);
|
const period = computePeriod(pay_year, period_no);
|
||||||
return this.buildOverview({
|
return this.buildOverview({
|
||||||
period_start: period.period_start,
|
period_start: period.period_start,
|
||||||
period_end : period.period_end,
|
period_end: period.period_end,
|
||||||
period_no : period.period_no,
|
period_no: period.period_no,
|
||||||
pay_year : period.pay_year,
|
pay_year: period.pay_year,
|
||||||
payday : period.payday,
|
payday: period.payday,
|
||||||
label :period.label,
|
label: period.label,
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//find crew member associated with supervisor
|
||||||
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
|
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
|
||||||
Promise<Array<{ id: number; first_name: string; last_name: string; email: string }>> {
|
Promise<Array<{ id: number; first_name: string; last_name: string; email: string }>> {
|
||||||
const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = [];
|
const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = [];
|
||||||
|
|
@ -69,6 +70,7 @@ export class PayPeriodsQueryService {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//fetchs crew emails
|
||||||
async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise<Set<string>> {
|
async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise<Set<string>> {
|
||||||
const crew = await this.resolveCrew(supervisor_id, include_subtree);
|
const crew = await this.resolveCrew(supervisor_id, include_subtree);
|
||||||
return new Set(crew.map(crew_member => crew_member.email).filter(Boolean));
|
return new Set(crew.map(crew_member => crew_member.email).filter(Boolean));
|
||||||
|
|
@ -82,7 +84,7 @@ export class PayPeriodsQueryService {
|
||||||
|
|
||||||
// 2) fetch supervisor
|
// 2) fetch supervisor
|
||||||
const supervisor = await this.prisma.employees.findFirst({
|
const supervisor = await this.prisma.employees.findFirst({
|
||||||
where: { user: { email: email }},
|
where: { user: { email: email } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
is_supervisor: true,
|
is_supervisor: true,
|
||||||
|
|
@ -99,27 +101,32 @@ export class PayPeriodsQueryService {
|
||||||
const seed_names = new Map<number, { name: string; email: string }>(
|
const seed_names = new Map<number, { name: string; email: string }>(
|
||||||
crew.map(crew => [
|
crew.map(crew => [
|
||||||
crew.id,
|
crew.id,
|
||||||
{ name:`${crew.first_name} ${crew.last_name}`.trim(),
|
{
|
||||||
email: crew.email }
|
name: `${crew.first_name} ${crew.last_name}`.trim(),
|
||||||
|
email: crew.email
|
||||||
|
}
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4) overview build
|
// 4) overview build
|
||||||
return this.buildOverview({
|
return this.buildOverview({
|
||||||
period_no : period.pay_period_no,
|
period_no: period.pay_period_no,
|
||||||
period_start: period.period_start,
|
period_start: period.period_start,
|
||||||
period_end : period.period_end,
|
period_end: period.period_end,
|
||||||
payday : period.payday,
|
payday: period.payday,
|
||||||
pay_year : period.pay_year,
|
pay_year: period.pay_year,
|
||||||
label : period.label,
|
label: period.label,
|
||||||
|
//add is_approved
|
||||||
}, { filtered_employee_ids: crew_ids, seed_names });
|
}, { filtered_employee_ids: crew_ids, seed_names });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildOverview(
|
private async buildOverview(
|
||||||
period: { period_start: string | Date; period_end: string | Date; payday: string | Date;
|
period: {
|
||||||
period_no: number; pay_year: number; label: string; },
|
period_start: string | Date; period_end: string | Date; payday: string | Date;
|
||||||
options?: { filtered_employee_ids?: number[]; seed_names?: Map<number, {name: string, email: string}>}
|
period_no: number; pay_year: number; label: string;
|
||||||
|
}, //add is_approved
|
||||||
|
options?: { filtered_employee_ids?: number[]; seed_names?: Map<number, { name: string, email: string }> }
|
||||||
): Promise<PayPeriodOverviewDto> {
|
): Promise<PayPeriodOverviewDto> {
|
||||||
const toDateString = (d: Date) => d.toISOString().slice(0, 10);
|
const toDateString = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number));
|
const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number));
|
||||||
|
|
@ -134,10 +141,10 @@ export class PayPeriodsQueryService {
|
||||||
|
|
||||||
const payd = period.payday instanceof Date
|
const payd = period.payday instanceof Date
|
||||||
? period.payday
|
? period.payday
|
||||||
: new Date (`${period.payday}T00:00:00.000Z`);
|
: new Date(`${period.payday}T00:00:00.000Z`);
|
||||||
|
|
||||||
//restrictEmployeeIds = filter for shifts and expenses by employees
|
//restrictEmployeeIds = filter for shifts and expenses by employees
|
||||||
const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } }: {};
|
const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } } : {};
|
||||||
|
|
||||||
// SHIFTS (filtered by crew)
|
// SHIFTS (filtered by crew)
|
||||||
const shifts = await this.prisma.shifts.findMany({
|
const shifts = await this.prisma.shifts.findMany({
|
||||||
|
|
@ -148,19 +155,25 @@ export class PayPeriodsQueryService {
|
||||||
select: {
|
select: {
|
||||||
start_time: true,
|
start_time: true,
|
||||||
end_time: true,
|
end_time: true,
|
||||||
timesheet: { select: {
|
is_remote: true,
|
||||||
|
timesheet: {
|
||||||
|
select: {
|
||||||
is_approved: true,
|
is_approved: true,
|
||||||
employee: { select: {
|
employee: {
|
||||||
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
user: { select: {
|
user: {
|
||||||
|
select: {
|
||||||
first_name: true,
|
first_name: true,
|
||||||
last_name: true,
|
last_name: true,
|
||||||
email: true,
|
email: true,
|
||||||
} },
|
}
|
||||||
} },
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
bank_code: { select: { categorie: true } },
|
},
|
||||||
|
bank_code: { select: { categorie: true, type: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -172,18 +185,24 @@ export class PayPeriodsQueryService {
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
amount: true,
|
amount: true,
|
||||||
timesheet: { select: {
|
timesheet: {
|
||||||
|
select: {
|
||||||
is_approved: true,
|
is_approved: true,
|
||||||
employee: { select: {
|
employee: {
|
||||||
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
user: { select: {
|
user: {
|
||||||
|
select: {
|
||||||
first_name: true,
|
first_name: true,
|
||||||
last_name: true,
|
last_name: true,
|
||||||
email: true,
|
email: true,
|
||||||
} },
|
}
|
||||||
} },
|
},
|
||||||
} },
|
}
|
||||||
bank_code: { select: { categorie: true, modifier: true } },
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bank_code: { select: { categorie: true, modifier: true, type: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -191,17 +210,24 @@ export class PayPeriodsQueryService {
|
||||||
|
|
||||||
// seed for employee without data
|
// seed for employee without data
|
||||||
if (options?.seed_names) {
|
if (options?.seed_names) {
|
||||||
for (const [id, {name, email}] of options.seed_names.entries()) {
|
for (const [id, { name, email }] of options.seed_names.entries()) {
|
||||||
by_employee.set(id, {
|
by_employee.set(id, {
|
||||||
email,
|
email,
|
||||||
employee_name: name,
|
employee_name: name,
|
||||||
regular_hours: 0,
|
regular_hours: 0,
|
||||||
|
other_hours: {
|
||||||
evening_hours: 0,
|
evening_hours: 0,
|
||||||
emergency_hours: 0,
|
emergency_hours: 0,
|
||||||
overtime_hours: 0,
|
overtime_hours: 0,
|
||||||
|
sick_hours: 0,
|
||||||
|
holiday_hours: 0,
|
||||||
|
vacation_hours: 0,
|
||||||
|
},
|
||||||
|
total_hours: 0,
|
||||||
expenses: 0,
|
expenses: 0,
|
||||||
mileage: 0,
|
mileage: 0,
|
||||||
is_approved: true,
|
is_approved: true,
|
||||||
|
is_remote: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,12 +238,19 @@ export class PayPeriodsQueryService {
|
||||||
email,
|
email,
|
||||||
employee_name: name,
|
employee_name: name,
|
||||||
regular_hours: 0,
|
regular_hours: 0,
|
||||||
|
other_hours: {
|
||||||
evening_hours: 0,
|
evening_hours: 0,
|
||||||
emergency_hours: 0,
|
emergency_hours: 0,
|
||||||
overtime_hours: 0,
|
overtime_hours: 0,
|
||||||
|
sick_hours: 0,
|
||||||
|
holiday_hours: 0,
|
||||||
|
vacation_hours: 0,
|
||||||
|
},
|
||||||
|
total_hours: 0,
|
||||||
expenses: 0,
|
expenses: 0,
|
||||||
mileage: 0,
|
mileage: 0,
|
||||||
is_approved: true,
|
is_approved: true,
|
||||||
|
is_remote: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return by_employee.get(id)!;
|
return by_employee.get(id)!;
|
||||||
|
|
@ -229,15 +262,33 @@ export class PayPeriodsQueryService {
|
||||||
const record = ensure(employee.id, name, employee.user.email);
|
const record = ensure(employee.id, name, employee.user.email);
|
||||||
|
|
||||||
const hours = computeHours(shift.start_time, shift.end_time);
|
const hours = computeHours(shift.start_time, shift.end_time);
|
||||||
const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase();
|
const type = (shift.bank_code?.type ?? '').toUpperCase();
|
||||||
switch (categorie) {
|
switch (type) {
|
||||||
case "EVENING": record.evening_hours += hours; break;
|
case "EVENING": record.other_hours.evening_hours += hours;
|
||||||
case "EMERGENCY":
|
record.total_hours += hours;
|
||||||
case "URGENT": record.emergency_hours += hours; break;
|
break;
|
||||||
case "OVERTIME": record.overtime_hours += hours; break;
|
case "EMERGENCY": record.other_hours.emergency_hours += hours;
|
||||||
default: record.regular_hours += hours; break;
|
record.total_hours += hours;
|
||||||
|
break;
|
||||||
|
case "OVERTIME": record.other_hours.overtime_hours += hours;
|
||||||
|
record.total_hours += hours;
|
||||||
|
break;
|
||||||
|
case "SICK": record.other_hours.sick_hours += hours;
|
||||||
|
record.total_hours += hours;
|
||||||
|
break;
|
||||||
|
case "HOLIDAY": record.other_hours.holiday_hours += hours;
|
||||||
|
record.total_hours += hours;
|
||||||
|
break;
|
||||||
|
case "VACATION": record.other_hours.vacation_hours += hours;
|
||||||
|
record.total_hours += hours;
|
||||||
|
break;
|
||||||
|
case "REGULAR": record.regular_hours += hours;
|
||||||
|
record.total_hours += hours;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
record.is_approved = record.is_approved && shift.timesheet.is_approved;
|
record.is_approved = record.is_approved && shift.timesheet.is_approved;
|
||||||
|
record.is_remote = record.is_remote || !!shift.is_remote;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const expense of expenses) {
|
for (const expense of expenses) {
|
||||||
|
|
@ -248,10 +299,10 @@ export class PayPeriodsQueryService {
|
||||||
const amount = toMoney(expense.amount);
|
const amount = toMoney(expense.amount);
|
||||||
record.expenses += amount;
|
record.expenses += amount;
|
||||||
|
|
||||||
const categorie = (expense.bank_code?.categorie || "").toUpperCase();
|
const type = (expense.bank_code?.type || "").toUpperCase();
|
||||||
const rate = expense.bank_code?.modifier ?? 0;
|
const rate = expense.bank_code?.modifier ?? 0;
|
||||||
if (categorie === "MILEAGE" && rate > 0) {
|
if (type === "MILEAGE" && rate > 0) {
|
||||||
record.mileage += amount / rate;
|
record.mileage += Math.round((amount / rate) * 100) / 100;
|
||||||
}
|
}
|
||||||
record.is_approved = record.is_approved && expense.timesheet.is_approved;
|
record.is_approved = record.is_approved && expense.timesheet.is_approved;
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +322,7 @@ export class PayPeriodsQueryService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSupervisor(email:string) {
|
async getSupervisor(email: string) {
|
||||||
return this.prisma.employees.findFirst({
|
return this.prisma.employees.findFirst({
|
||||||
where: { user: { email } },
|
where: { user: { email } },
|
||||||
select: { id: true, is_supervisor: true },
|
select: { id: true, is_supervisor: true },
|
||||||
|
|
@ -280,13 +331,14 @@ export class PayPeriodsQueryService {
|
||||||
|
|
||||||
async findAll(): Promise<PayPeriodDto[]> {
|
async findAll(): Promise<PayPeriodDto[]> {
|
||||||
const currentPayYear = payYearOfDate(new Date());
|
const currentPayYear = payYearOfDate(new Date());
|
||||||
return listPayYear(currentPayYear).map(period =>({
|
return listPayYear(currentPayYear).map(period => ({
|
||||||
pay_period_no: period.period_no,
|
pay_period_no: period.period_no,
|
||||||
pay_year: period.pay_year,
|
pay_year: period.pay_year,
|
||||||
payday: period.payday,
|
payday: period.payday,
|
||||||
period_start: period.period_start,
|
period_start: period.period_start,
|
||||||
period_end: period.period_end,
|
period_end: period.period_end,
|
||||||
label: period.label,
|
label: period.label,
|
||||||
|
//add is_approved
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +352,7 @@ export class PayPeriodsQueryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async findCurrent(date?: string): Promise<PayPeriodDto> {
|
async findCurrent(date?: string): Promise<PayPeriodDto> {
|
||||||
const iso_day = date ?? new Date().toISOString().slice(0,10);
|
const iso_day = date ?? new Date().toISOString().slice(0, 10);
|
||||||
return this.findByDate(iso_day);
|
return this.findByDate(iso_day);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +360,7 @@ export class PayPeriodsQueryService {
|
||||||
const row = await this.prisma.payPeriods.findFirst({
|
const row = await this.prisma.payPeriods.findFirst({
|
||||||
where: { pay_year, pay_period_no: period_no },
|
where: { pay_year, pay_period_no: period_no },
|
||||||
});
|
});
|
||||||
if(row) return mapPayPeriodToDto(row);
|
if (row) return mapPayPeriodToDto(row);
|
||||||
|
|
||||||
// fallback for outside of view periods
|
// fallback for outside of view periods
|
||||||
const period = computePeriod(pay_year, period_no);
|
const period = computePeriod(pay_year, period_no);
|
||||||
|
|
@ -328,27 +380,27 @@ export class PayPeriodsQueryService {
|
||||||
const row = await this.prisma.payPeriods.findFirst({
|
const row = await this.prisma.payPeriods.findFirst({
|
||||||
where: { period_start: { lte: dt }, period_end: { gte: dt } },
|
where: { period_start: { lte: dt }, period_end: { gte: dt } },
|
||||||
});
|
});
|
||||||
if(row) return mapPayPeriodToDto(row);
|
if (row) return mapPayPeriodToDto(row);
|
||||||
|
|
||||||
//fallback for outwside view periods
|
//fallback for outwside view periods
|
||||||
const pay_year = payYearOfDate(date);
|
const pay_year = payYearOfDate(date);
|
||||||
const periods = listPayYear(pay_year);
|
const periods = listPayYear(pay_year);
|
||||||
const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
|
const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
|
||||||
if(!hit) throw new NotFoundException(`No period found for ${date}`);
|
if (!hit) throw new NotFoundException(`No period found for ${date}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pay_period_no: hit.period_no,
|
pay_period_no: hit.period_no,
|
||||||
pay_year : hit.pay_year,
|
pay_year: hit.pay_year,
|
||||||
period_start : hit.period_start,
|
period_start: hit.period_start,
|
||||||
period_end : hit.period_end,
|
period_end: hit.period_end,
|
||||||
payday : hit.payday,
|
payday: hit.payday,
|
||||||
label : hit.label
|
label: hit.label
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPeriodWindow(pay_year: number, period_no: number) {
|
async getPeriodWindow(pay_year: number, period_no: number) {
|
||||||
return this.prisma.payPeriods.findFirst({
|
return this.prisma.payPeriods.findFirst({
|
||||||
where: {pay_year, pay_period_no: period_no },
|
where: { pay_year, pay_period_no: period_no },
|
||||||
select: { period_start: true, period_end: true },
|
select: { period_start: true, period_end: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Body, Controller, Param, Patch } from "@nestjs/common";
|
||||||
|
import { PreferencesService } from "../services/preferences.service";
|
||||||
|
import { PreferencesDto } from "../dtos/preferences.dto";
|
||||||
|
|
||||||
|
@Controller('preferences')
|
||||||
|
export class PreferencesController {
|
||||||
|
constructor(private readonly service: PreferencesService){}
|
||||||
|
|
||||||
|
@Patch(':email')
|
||||||
|
async updatePreferences(@Param('email') email: string, @Body()payload: PreferencesDto) {
|
||||||
|
return this.service.updatePreferences(email, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
16
src/modules/preferences/dtos/preferences.dto.ts
Normal file
16
src/modules/preferences/dtos/preferences.dto.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { IsInt } from "class-validator";
|
||||||
|
|
||||||
|
export class PreferencesDto {
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
notifications: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
dark_mode: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
lang_switch: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
lefty_mode: number;
|
||||||
|
}
|
||||||
13
src/modules/preferences/preferences.module.ts
Normal file
13
src/modules/preferences/preferences.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PreferencesController } from "./controllers/preferences.controller";
|
||||||
|
import { PreferencesService } from "./services/preferences.service";
|
||||||
|
import { SharedModule } from "../shared/shared.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [SharedModule],
|
||||||
|
controllers: [ PreferencesController ],
|
||||||
|
providers: [ PreferencesService ],
|
||||||
|
exports: [ PreferencesService ],
|
||||||
|
})
|
||||||
|
|
||||||
|
export class PreferencesModule {}
|
||||||
27
src/modules/preferences/services/preferences.service.ts
Normal file
27
src/modules/preferences/services/preferences.service.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Preferences } from "@prisma/client";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { PreferencesDto } from "../dtos/preferences.dto";
|
||||||
|
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PreferencesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly emailResolver: EmailToIdResolver ,
|
||||||
|
){}
|
||||||
|
|
||||||
|
async updatePreferences(email: string, dto: PreferencesDto ): Promise<Preferences> {
|
||||||
|
const user_id = await this.emailResolver.resolveUserIdWithEmail(email);
|
||||||
|
return this.prisma.preferences.update({
|
||||||
|
where: { user_id },
|
||||||
|
data: {
|
||||||
|
notifications: dto.notifications,
|
||||||
|
dark_mode: dto.dark_mode,
|
||||||
|
lang_switch: dto.lang_switch,
|
||||||
|
lefty_mode: dto.lefty_mode,
|
||||||
|
},
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Post, Put, Query } from "@nestjs/common";
|
||||||
|
import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto";
|
||||||
|
import { SchedulePresetsCommandService } from "../services/schedule-presets-command.service";
|
||||||
|
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
||||||
|
import { SchedulePresetsQueryService } from "../services/schedule-presets-query.service";
|
||||||
|
|
||||||
|
@Controller('schedule-presets')
|
||||||
|
export class SchedulePresetsController {
|
||||||
|
constructor(
|
||||||
|
private readonly commandService: SchedulePresetsCommandService,
|
||||||
|
private readonly queryService: SchedulePresetsQueryService,
|
||||||
|
){}
|
||||||
|
|
||||||
|
//used to create, update or delete a schedule preset
|
||||||
|
@Put(':email')
|
||||||
|
async upsert(
|
||||||
|
@Param('email') email: string,
|
||||||
|
@Query('action') action: UpsertAction,
|
||||||
|
@Body() dto: SchedulePresetsDto,
|
||||||
|
) {
|
||||||
|
const actions: UpsertAction[] = ['create','update','delete'];
|
||||||
|
if(!actions) throw new NotFoundException(`No action found for ${actions}`)
|
||||||
|
return this.commandService.upsertSchedulePreset(email, action, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
//used to show the list of available schedule presets
|
||||||
|
@Get(':email')
|
||||||
|
async findListByEmail(
|
||||||
|
@Param('email') email: string,
|
||||||
|
) {
|
||||||
|
return this.queryService.findSchedulePresetsByEmail(email);
|
||||||
|
}
|
||||||
|
//used to apply a preset to a timesheet
|
||||||
|
@Post('/apply-presets/:email')
|
||||||
|
async applyPresets(
|
||||||
|
@Param('email') email: string,
|
||||||
|
@Query('preset') preset_name: string,
|
||||||
|
@Query('start') start_date: string,
|
||||||
|
) {
|
||||||
|
if(!preset_name?.trim()) throw new BadRequestException('Query "preset" is required');
|
||||||
|
if(!start_date?.trim()) throw new BadRequestException('Query "start" is required YYYY-MM-DD');
|
||||||
|
return this.applyPresets(email, preset_name, start_date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, Min } from "class-validator";
|
||||||
|
import { Weekday } from "@prisma/client";
|
||||||
|
|
||||||
|
export class SchedulePresetShiftsDto {
|
||||||
|
@IsEnum(Weekday)
|
||||||
|
week_day!: Weekday;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
sort_order!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(HH_MM_REGEX)
|
||||||
|
start_time!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(HH_MM_REGEX)
|
||||||
|
end_time!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
is_remote?: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { ArrayMinSize, IsArray, IsBoolean, IsEmail, IsOptional, IsString } from "class-validator";
|
||||||
|
import { SchedulePresetShiftsDto } from "./create-schedule-preset-shifts.dto";
|
||||||
|
|
||||||
|
export class SchedulePresetsDto {
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
is_default: boolean;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
preset_shifts: SchedulePresetShiftsDto[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { Weekday } from "@prisma/client";
|
||||||
|
|
||||||
|
export const WEEKDAY: Weekday[] = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
|
||||||
23
src/modules/schedule-presets/schedule-presets.module.ts
Normal file
23
src/modules/schedule-presets/schedule-presets.module.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SchedulePresetsCommandService } from "./services/schedule-presets-command.service";
|
||||||
|
import { SchedulePresetsQueryService } from "./services/schedule-presets-query.service";
|
||||||
|
import { SchedulePresetsController } from "./controller/schedule-presets.controller";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { SchedulePresetsApplyService } from "./services/schedule-presets-apply.service";
|
||||||
|
import { SharedModule } from "../shared/shared.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [SharedModule],
|
||||||
|
controllers: [SchedulePresetsController],
|
||||||
|
providers: [
|
||||||
|
PrismaService,
|
||||||
|
SchedulePresetsCommandService,
|
||||||
|
SchedulePresetsQueryService,
|
||||||
|
SchedulePresetsApplyService,
|
||||||
|
],
|
||||||
|
exports:[
|
||||||
|
SchedulePresetsCommandService,
|
||||||
|
SchedulePresetsQueryService,
|
||||||
|
SchedulePresetsApplyService,
|
||||||
|
],
|
||||||
|
}) export class SchedulePresetsModule {}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { ApplyResult } from "../types/schedule-presets.types";
|
||||||
|
import { Prisma, Weekday } from "@prisma/client";
|
||||||
|
import { WEEKDAY } from "../mappers/schedule-presets.mappers";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SchedulePresetsApplyService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly emailResolver: EmailToIdResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async applyToTimesheet(
|
||||||
|
email: string,
|
||||||
|
preset_name: string,
|
||||||
|
start_date_iso: string,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required');
|
||||||
|
if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD');
|
||||||
|
|
||||||
|
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
|
||||||
|
|
||||||
|
const preset = await this.prisma.schedulePresets.findFirst({
|
||||||
|
where: { employee_id, name: preset_name },
|
||||||
|
include: {
|
||||||
|
shifts: {
|
||||||
|
orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}],
|
||||||
|
select: {
|
||||||
|
week_day: true,
|
||||||
|
sort_order: true,
|
||||||
|
start_time: true,
|
||||||
|
end_time: true,
|
||||||
|
is_remote: true,
|
||||||
|
bank_code_id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if(!preset) throw new NotFoundException(`Preset ${preset} not found`);
|
||||||
|
|
||||||
|
const start_date = new Date(`${start_date_iso}T00:00:00.000Z`);
|
||||||
|
const timesheet = await this.prisma.timesheets.upsert({
|
||||||
|
where: { employee_id_start_date: { employee_id, start_date: start_date} },
|
||||||
|
update: {},
|
||||||
|
create: { employee_id, start_date: start_date },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
//index shifts by weekday
|
||||||
|
const index_by_day = new Map<Weekday, typeof preset.shifts>();
|
||||||
|
for (const shift of preset.shifts) {
|
||||||
|
const list = index_by_day.get(shift.week_day) ?? [];
|
||||||
|
list.push(shift);
|
||||||
|
index_by_day.set(shift.week_day, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addDays = (date: Date, days: number) =>
|
||||||
|
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days));
|
||||||
|
|
||||||
|
const overlaps = (aStart: Date, aEnd: Date, bStart: Date, bEnd: Date) =>
|
||||||
|
aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime();
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
for(let i = 0; i < 7; i++) {
|
||||||
|
const date = addDays(start_date, i);
|
||||||
|
const week_day = WEEKDAY[date.getUTCDay()];
|
||||||
|
const shifts = index_by_day.get(week_day) ?? [];
|
||||||
|
|
||||||
|
if(shifts.length === 0) continue;
|
||||||
|
|
||||||
|
const existing = await tx.shifts.findMany({
|
||||||
|
where: { timesheet_id: timesheet.id, date: date },
|
||||||
|
orderBy: { start_time: 'asc' },
|
||||||
|
select: {
|
||||||
|
start_time: true,
|
||||||
|
end_time: true,
|
||||||
|
bank_code_id: true,
|
||||||
|
is_remote: true,
|
||||||
|
comment: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: Prisma.ShiftsCreateManyInput[] = [];
|
||||||
|
|
||||||
|
for(const shift of shifts) {
|
||||||
|
if(shift.end_time.getTime() <= shift.start_time.getTime()) {
|
||||||
|
throw new ConflictException(`Invalid time range in preset day: ${week_day}, order: ${shift.sort_order}`);
|
||||||
|
}
|
||||||
|
const conflict = existing.find((existe)=> overlaps(
|
||||||
|
shift.start_time, shift.end_time ,
|
||||||
|
existe.start_time, existe.end_time,
|
||||||
|
));
|
||||||
|
if(conflict) {
|
||||||
|
throw new ConflictException({
|
||||||
|
error_code: 'SHIFT_OVERLAP_WITH_EXISTING',
|
||||||
|
mesage: `Preset shift overlaps existing shift on ${start_date} + ${i}(week day ${week_day})`,
|
||||||
|
conflict: {
|
||||||
|
existing_start: conflict.start_time.toISOString().slice(11,16),
|
||||||
|
existing_end: conflict.end_time.toISOString().slice(11,16),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
payload.push({
|
||||||
|
timesheet_id: timesheet.id,
|
||||||
|
date: date,
|
||||||
|
start_time: shift.start_time,
|
||||||
|
end_time: shift.end_time,
|
||||||
|
is_remote: shift.is_remote,
|
||||||
|
comment: null,
|
||||||
|
bank_code_id: shift.bank_code_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(payload.length) {
|
||||||
|
const response = await tx.shifts.createMany({ data: payload, skipDuplicates: true });
|
||||||
|
created += response.count;
|
||||||
|
skipped += payload.length - response.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { timesheet_id: timesheet.id, created, skipped };
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user