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",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.14.0",
|
||||
"prisma": "^6.17.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
|
|
@ -3633,9 +3633,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz",
|
||||
"integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz",
|
||||
"integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"c12": "3.1.0",
|
||||
|
|
@ -3645,48 +3645,48 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz",
|
||||
"integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz",
|
||||
"integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz",
|
||||
"integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz",
|
||||
"integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.14.0",
|
||||
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
|
||||
"@prisma/fetch-engine": "6.14.0",
|
||||
"@prisma/get-platform": "6.14.0"
|
||||
"@prisma/debug": "6.17.0",
|
||||
"@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
|
||||
"@prisma/fetch-engine": "6.17.0",
|
||||
"@prisma/get-platform": "6.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz",
|
||||
"integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==",
|
||||
"version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz",
|
||||
"integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz",
|
||||
"integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz",
|
||||
"integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.14.0",
|
||||
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
|
||||
"@prisma/get-platform": "6.14.0"
|
||||
"@prisma/debug": "6.17.0",
|
||||
"@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
|
||||
"@prisma/get-platform": "6.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz",
|
||||
"integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz",
|
||||
"integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.14.0"
|
||||
"@prisma/debug": "6.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
|
|
@ -10078,15 +10078,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/nypm": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz",
|
||||
"integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==",
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
||||
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"consola": "^3.4.2",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^2.2.0",
|
||||
"pkg-types": "^2.3.0",
|
||||
"tinyexec": "^1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -10595,9 +10595,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
|
|
@ -10677,14 +10677,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz",
|
||||
"integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz",
|
||||
"integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.14.0",
|
||||
"@prisma/engines": "6.14.0"
|
||||
"@prisma/config": "6.17.0",
|
||||
"@prisma/engines": "6.17.0"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
"globals": "^16.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.14.0",
|
||||
"prisma": "^6.17.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"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;
|
||||
|
|
@ -6,16 +6,17 @@ async function main() {
|
|||
const presets = [
|
||||
// type, categorie, modifier, bank_code
|
||||
['REGULAR' ,'SHIFT' , 1.0 , 'G1' ],
|
||||
['EVENING' ,'SHIFT', 1.25, 'G43'],
|
||||
['Emergency','SHIFT', 2 , 'G48'],
|
||||
['HOLIDAY' ,'SHIFT', 2.0 , 'G700'],
|
||||
|
||||
['EXPENSES','EXPENSE', 1.0 , 'G517'],
|
||||
['MILEAGE' ,'EXPENSE', 0.72, 'G57'],
|
||||
['OVERTIME' ,'SHIFT' , 2 , 'G43' ],
|
||||
['EMERGENCY' ,'SHIFT' , 2 , 'G48' ],
|
||||
['EVENING' ,'SHIFT' , 1.25 , 'G56' ],
|
||||
['SICK' ,'SHIFT' , 1.0 , 'G105'],
|
||||
['HOLIDAY' ,'SHIFT' , 1.0 , 'G104'],
|
||||
['VACATION' ,'SHIFT' , 1.0 , 'G305'],
|
||||
['ON_CALL' ,'EXPENSE' , 1.0 , 'G202'],
|
||||
['COMMISSION' ,'EXPENSE' , 1.0 , 'G234'],
|
||||
['PER_DIEM' ,'EXPENSE' , 1.0 , 'G502'],
|
||||
|
||||
['SICK' ,'LEAVE', 1.0, 'G105'],
|
||||
['VACATION' ,'LEAVE', 1.0, 'G305'],
|
||||
['MILEAGE' ,'EXPENSE' , 0.72 , 'G503'],
|
||||
['EXPENSES' ,'EXPENSE' , 1.0 , 'G517'],
|
||||
];
|
||||
|
||||
await prisma.bankCodes.createMany({
|
||||
|
|
|
|||
|
|
@ -1,71 +1,200 @@
|
|||
import { PrismaClient, Roles } from '@prisma/client';
|
||||
|
||||
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) {
|
||||
return `user${i + 1}@example.test`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 50 users total: 40 employees + 10 customers
|
||||
// Roles distribution for the 40 employees:
|
||||
// 1 ADMIN, 4 SUPERVISOR, 1 HR, 1 ACCOUNTING, 33 EMPLOYEE
|
||||
// 10 CUSTOMER (non-employees)
|
||||
const usersData: {
|
||||
type UserSeed = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone_number: number;
|
||||
phone_number: string;
|
||||
residence?: string | null;
|
||||
role: Roles;
|
||||
}[] = [];
|
||||
};
|
||||
|
||||
const usersData: UserSeed[] = [];
|
||||
|
||||
const firstNames = ['Alex', 'Sam', 'Chris', 'Jordan', 'Taylor', 'Morgan', 'Jamie', 'Robin', 'Avery', 'Casey'];
|
||||
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Wilson', 'Taylor', 'Clark'];
|
||||
|
||||
// helper to pick
|
||||
const pick = <T>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
|
||||
const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
const rolesForEmployees: Roles[] = [
|
||||
Roles.ADMIN,
|
||||
...Array(4).fill(Roles.SUPERVISOR),
|
||||
Roles.HR,
|
||||
Roles.ACCOUNTING,
|
||||
...Array(33).fill(Roles.EMPLOYEE),
|
||||
/**
|
||||
* Objectif total: 50 users
|
||||
* - 39 employés génériques (dont ADMIN=1, SUPERVISOR=3, HR=1, ACCOUNTING=1, EMPLOYEE=33)
|
||||
* - +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
|
||||
for (let i = 0; i < 40; i++) {
|
||||
|
||||
// --- 39 employés génériques: user1..user39@example.test
|
||||
for (let i = 0; i < 39; i++) {
|
||||
const fn = pick(firstNames);
|
||||
const ln = pick(lastNames);
|
||||
usersData.push({
|
||||
first_name: fn,
|
||||
last_name: ln,
|
||||
email: emailFor(i),
|
||||
phone_number: BASE_PHONE + i,
|
||||
phone_number: BASE_PHONE + i.toString(),
|
||||
residence: Math.random() < 0.5 ? 'QC' : 'ON',
|
||||
role: rolesForEmployees[i],
|
||||
role: rolesForEmployees39[i],
|
||||
});
|
||||
}
|
||||
|
||||
// 10 customers
|
||||
for (let i = 40; i < 50; i++) {
|
||||
// --- 10 customers: user40..user49@example.test
|
||||
for (let i = 39; i < 49; i++) {
|
||||
const fn = pick(firstNames);
|
||||
const ln = pick(lastNames);
|
||||
usersData.push({
|
||||
first_name: fn,
|
||||
last_name: ln,
|
||||
email: emailFor(i),
|
||||
phone_number: BASE_PHONE + i,
|
||||
phone_number: BASE_PHONE + i.toString(),
|
||||
residence: Math.random() < 0.5 ? 'QC' : 'ON',
|
||||
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 });
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
// 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()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,46 +32,52 @@ function randomTitle() {
|
|||
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() {
|
||||
const employeeUsers = await prisma.users.findMany({
|
||||
where: { role: { in: [Roles.ADMIN, Roles.SUPERVISOR, Roles.HR, Roles.ACCOUNTING, Roles.EMPLOYEE] } },
|
||||
orderBy: { email: 'asc' },
|
||||
});
|
||||
|
||||
// Create supervisors first
|
||||
const supervisorUsers = employeeUsers.filter(u => u.role === Roles.SUPERVISOR);
|
||||
const supervisorEmployeeIds: number[] = [];
|
||||
// 1) Trouver le user qui sera le superviseur fixe
|
||||
const supervisorUser = await prisma.users.findUnique({
|
||||
where: { email: 'user5@example.test' },
|
||||
});
|
||||
if (!supervisorUser) {
|
||||
throw new Error("Le user 'user5@example.test' n'existe pas !");
|
||||
}
|
||||
|
||||
for (const u of supervisorUsers) {
|
||||
const emp = await prisma.employees.upsert({
|
||||
where: { user_id: u.id },
|
||||
update: {},
|
||||
// 2) Créer ou récupérer son employee avec is_supervisor = true
|
||||
const supervisorEmp = await prisma.employees.upsert({
|
||||
where: { user_id: supervisorUser.id },
|
||||
update: { is_supervisor: true },
|
||||
create: {
|
||||
user_id: u.id,
|
||||
user_id: supervisorUser.id,
|
||||
external_payroll_id: randInt(10000, 99999),
|
||||
company_code: randInt(1, 5),
|
||||
company_code: randomCompanyCode(),
|
||||
first_work_day: randomPastDate(3),
|
||||
last_work_day: null,
|
||||
job_title: randomTitle(),
|
||||
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) {
|
||||
const already = await prisma.employees.findUnique({ where: { user_id: u.id } });
|
||||
if (already) continue;
|
||||
|
||||
const supervisor_id =
|
||||
u.role === Roles.ADMIN ? null : supervisorEmployeeIds[randInt(0, supervisorEmployeeIds.length - 1)];
|
||||
const supervisor_id = u.role === Roles.ADMIN ? null : supervisorEmp.id;
|
||||
|
||||
await prisma.employees.create({
|
||||
data: {
|
||||
user_id: u.id,
|
||||
external_payroll_id: randInt(10000, 99999),
|
||||
company_code: randInt(1, 5),
|
||||
company_code: randomCompanyCode(),
|
||||
first_work_day: randomPastDate(3),
|
||||
last_work_day: null,
|
||||
supervisor_id,
|
||||
|
|
@ -81,7 +87,7 @@ async function main() {
|
|||
}
|
||||
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
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();
|
||||
|
||||
function daysAgo(n: number) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
// prisma/mock-seeds-scripts/06-customers-archive.ts
|
||||
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();
|
||||
|
||||
async function main() {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
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();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -14,7 +19,7 @@ async function main() {
|
|||
const employees = await prisma.employees.findMany({ select: { id: true } });
|
||||
const bankCodes = await prisma.bankCodes.findMany({
|
||||
where: { categorie: 'LEAVE' },
|
||||
select: { id: true },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
|
||||
if (!employees.length || !bankCodes.length) {
|
||||
|
|
@ -39,30 +44,31 @@ async function main() {
|
|||
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[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const emp = employees[i % employees.length];
|
||||
const m = futureMonths[i % futureMonths.length];
|
||||
const start = dateOn(year, m, 5 + i); // 5..14
|
||||
if (start <= today) continue; // garantir "futur"
|
||||
const date = dateOn(year, m, 5 + i); // 5..14
|
||||
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 status = statuses[i % statuses.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({
|
||||
employee_id: emp.id,
|
||||
bank_code_id: bc.id,
|
||||
leave_type: type,
|
||||
start_date_time: start,
|
||||
end_date_time: end, // ok: Date | null
|
||||
comment: `Future leave #${i + 1}`,
|
||||
date,
|
||||
comment: `Future leave #${i + 1} (${bc.type})`,
|
||||
approval_status: status,
|
||||
requested_hours: requestedHours,
|
||||
payable_hours: payableHours,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +76,7 @@ async function main() {
|
|||
await prisma.leaveRequests.createMany({ data: rows });
|
||||
}
|
||||
|
||||
console.log(`✓ LeaveRequests (future): ${rows.length} rows`);
|
||||
console.log(`? LeaveRequests (future): ${rows.length} rows`);
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
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();
|
||||
|
||||
|
|
@ -11,32 +16,54 @@ function daysAgo(n:number) {
|
|||
|
||||
async function main() {
|
||||
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 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[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const COUNT = 12;
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const emp = employees[i % employees.length];
|
||||
const bc = bankCodes[i % bankCodes.length];
|
||||
const start = daysAgo(120 - i * 3); // tous avant aujourd'hui
|
||||
const end = Math.random() < 0.4 ? null : daysAgo(119 - i * 3);
|
||||
const leaveCode = leaveCodes[Math.floor(Math.random() * leaveCodes.length)];
|
||||
|
||||
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({
|
||||
data: {
|
||||
employee_id: emp.id,
|
||||
bank_code_id: bc.id,
|
||||
leave_type: types[i % types.length],
|
||||
start_date_time: start,
|
||||
end_date_time: end,
|
||||
comment: `Past leave #${i+1}`,
|
||||
approval_status: statuses[(i+2) % statuses.length],
|
||||
bank_code_id: leaveCode.id,
|
||||
leave_type: leaveCode.type as LeaveTypes,
|
||||
date,
|
||||
comment: `Past leave #${i + 1} (${leaveCode.type})`,
|
||||
approval_status: status,
|
||||
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) {
|
||||
|
|
@ -45,15 +72,16 @@ async function main() {
|
|||
leave_request_id: lr.id,
|
||||
employee_id: lr.employee_id,
|
||||
leave_type: lr.leave_type,
|
||||
start_date_time: lr.start_date_time,
|
||||
end_date_time: lr.end_date_time,
|
||||
date: lr.date,
|
||||
comment: lr.comment,
|
||||
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());
|
||||
|
|
@ -2,26 +2,59 @@ import { PrismaClient, Prisma } from '@prisma/client';
|
|||
|
||||
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() {
|
||||
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[] = [];
|
||||
|
||||
// 8 timesheets / employee
|
||||
for (const e of employees) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const is_approved = Math.random() < 0.3;
|
||||
rows.push({ employee_id: e.id, is_approved });
|
||||
for (const monday of mondays) {
|
||||
rows.push({
|
||||
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) {
|
||||
await prisma.timesheets.createMany({ data: rows });
|
||||
await prisma.timesheets.createMany({ data: rows, skipDuplicates: true });
|
||||
}
|
||||
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -2,54 +2,233 @@ import { PrismaClient } from '@prisma/client';
|
|||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ====== Config ======
|
||||
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) {
|
||||
// stocker une heure (Postgres TIME) via Date (UTC 1970-01-01)
|
||||
return new Date(Date.UTC(1970, 0, 1, hour, minute, 0));
|
||||
}
|
||||
function daysAgo(n:number) {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() - n);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const bankCodes = await prisma.bankCodes.findMany({ where: { categorie: 'SHIFT' }, select: { id: true } });
|
||||
if (!bankCodes.length) throw new Error('Need SHIFT bank codes');
|
||||
|
||||
const employees = await prisma.employees.findMany({ select: { id: true } });
|
||||
|
||||
for (const e of employees) {
|
||||
const tss = await prisma.timesheets.findMany({
|
||||
where: { employee_id: e.id },
|
||||
// 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 },
|
||||
});
|
||||
if (!tss.length) continue;
|
||||
}
|
||||
|
||||
// 10 shifts / employee
|
||||
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
|
||||
async function main() {
|
||||
// --- Bank codes (pondérés: surtout G1 = régulier) ---
|
||||
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 } });
|
||||
if (!employees.length) {
|
||||
console.log('Aucun employé — rien à insérer.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mondayThisWeek = mondayOfThisWeekUTC();
|
||||
const mondays: Date[] = [];
|
||||
if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
|
||||
for (let n = 1; n <= PREVIOUS_WEEKS; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
|
||||
|
||||
let created = 0;
|
||||
|
||||
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({
|
||||
data: {
|
||||
timesheet_id: ts.id,
|
||||
bank_code_id: bc.id,
|
||||
description: `Shift ${i + 1} for emp ${e.id}`,
|
||||
bank_code_id: bcMorningId,
|
||||
comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcMorningCode}`,
|
||||
date,
|
||||
start_time: timeAt(startH, 0),
|
||||
end_time: timeAt(endH, 0),
|
||||
is_approved: Math.random() < 0.5,
|
||||
start_time: timeAt(startH, startM),
|
||||
end_time: timeAt(lunchStartHM.h, lunchStartHM.m),
|
||||
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();
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
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();
|
||||
|
||||
function timeAt(h:number,m:number) {
|
||||
|
|
@ -21,7 +26,7 @@ async function main() {
|
|||
if (!tss.length) continue;
|
||||
|
||||
const createdShiftIds: number[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const ts = tss[i % tss.length];
|
||||
const bc = bankCodes[i % bankCodes.length];
|
||||
const date = daysAgo(200 + i); // bien dans le passé
|
||||
|
|
@ -32,7 +37,7 @@ async function main() {
|
|||
data: {
|
||||
timesheet_id: ts.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,
|
||||
start_time: timeAt(startH, 0),
|
||||
end_time: timeAt(endH, 0),
|
||||
|
|
@ -50,7 +55,7 @@ async function main() {
|
|||
shift_id: s.id,
|
||||
timesheet_id: s.timesheet_id,
|
||||
bank_code_id: s.bank_code_id,
|
||||
description: s.description,
|
||||
comment: s.comment,
|
||||
date: s.date,
|
||||
start_time: s.start_time,
|
||||
end_time: s.end_time,
|
||||
|
|
|
|||
|
|
@ -2,43 +2,165 @@ import { PrismaClient } from '@prisma/client';
|
|||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function daysAgo(n:number) {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() - n);
|
||||
// ====== Config ======
|
||||
const WEEKS_BACK = 4; // 4 semaines avant + semaine courante
|
||||
const INCLUDE_CURRENT = true; // inclure la semaine courante
|
||||
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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const expenseCodes = await prisma.bankCodes.findMany({ where: { categorie: 'EXPENSE' }, select: { id: true } });
|
||||
if (!expenseCodes.length) throw new Error('Need EXPENSE bank codes');
|
||||
|
||||
const timesheets = await prisma.timesheets.findMany({ select: { id: true } });
|
||||
if (!timesheets.length) {
|
||||
console.warn('No timesheets found; aborting expenses seed.');
|
||||
return;
|
||||
function mondayNWeeksBefore(monday: Date, n: number) {
|
||||
const d = new Date(monday);
|
||||
d.setUTCDate(monday.getUTCDate() - n * 7);
|
||||
return d;
|
||||
}
|
||||
|
||||
// 5 expenses distribuées aléatoirement parmi les employés (via timesheets)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const ts = timesheets[Math.floor(Math.random() * timesheets.length)];
|
||||
const bc = expenseCodes[i % expenseCodes.length];
|
||||
await prisma.expenses.create({
|
||||
data: {
|
||||
timesheet_id: ts.id,
|
||||
bank_code_id: bc.id,
|
||||
date: daysAgo(3 + i),
|
||||
amount: (50 + i * 10).toFixed(2),
|
||||
attachement: null,
|
||||
description: `Expense #${i + 1}`,
|
||||
is_approved: Math.random() < 0.5,
|
||||
supervisor_comment: Math.random() < 0.3 ? 'OK' : null,
|
||||
},
|
||||
// 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() {
|
||||
// Codes d'EXPENSES (exemples)
|
||||
const BANKS = ['G517', 'G503', 'G502', 'G202'] as const;
|
||||
|
||||
// Précharger les bank codes
|
||||
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(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;
|
||||
}
|
||||
|
||||
// Liste des lundis (courant + 4 précédents)
|
||||
const mondayThisWeek = mondayOfThisWeekUTC();
|
||||
const mondays: Date[] = [];
|
||||
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({
|
||||
data: {
|
||||
timesheet_id: ts.id,
|
||||
bank_code_id,
|
||||
date,
|
||||
amount,
|
||||
mileage,
|
||||
attachment: null,
|
||||
comment: `Expense ${code} (emp ${e.id})`,
|
||||
is_approved: Math.random() < 0.65,
|
||||
supervisor_comment: Math.random() < 0.25 ? 'OK' : null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
// 13-expenses-archive.ts
|
||||
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();
|
||||
|
||||
function daysAgo(n:number) {
|
||||
|
|
@ -17,7 +22,7 @@ async function main() {
|
|||
// ✅ typer pour éviter never[]
|
||||
const created: Expenses[] = [];
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ts = timesheets[i % timesheets.length];
|
||||
const bc = expenseCodes[i % expenseCodes.length];
|
||||
|
||||
|
|
@ -27,8 +32,8 @@ async function main() {
|
|||
bank_code_id: bc.id,
|
||||
date: daysAgo(60 + i),
|
||||
amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string
|
||||
attachement: null,
|
||||
description: `Old expense #${i + 1}`,
|
||||
attachment: null,
|
||||
comment: `Old expense #${i + 1}`,
|
||||
is_approved: true,
|
||||
supervisor_comment: null,
|
||||
},
|
||||
|
|
@ -45,8 +50,8 @@ async function main() {
|
|||
bank_code_id: e.bank_code_id,
|
||||
date: e.date,
|
||||
amount: e.amount,
|
||||
attachement: e.attachement,
|
||||
description: e.description,
|
||||
attachment: e.attachment,
|
||||
comment: e.comment,
|
||||
is_approved: e.is_approved,
|
||||
supervisor_comment: e.supervisor_comment,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ model Users {
|
|||
first_name String
|
||||
last_name String
|
||||
email String @unique
|
||||
phone_number Int @unique
|
||||
phone_number String @unique
|
||||
residence String?
|
||||
role Roles @default(GUEST)
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ model Users {
|
|||
oauth_sessions OAuthSessions[] @relation("UserOAuthSessions")
|
||||
employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive")
|
||||
customer_archive CustomersArchive[] @relation("UserToCustomersToArchive")
|
||||
preferences Preferences? @relation("UserPreferences")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
@ -36,6 +37,9 @@ model Employees {
|
|||
id Int @id @default(autoincrement())
|
||||
user Users @relation("UserEmployee", fields: [user_id], references: [id])
|
||||
user_id String @unique @db.Uuid
|
||||
supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id])
|
||||
supervisor_id Int?
|
||||
|
||||
external_payroll_id Int
|
||||
company_code Int
|
||||
first_work_day DateTime @db.Date
|
||||
|
|
@ -43,14 +47,13 @@ model Employees {
|
|||
job_title String?
|
||||
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")
|
||||
timesheet Timesheets[] @relation("TimesheetEmployee")
|
||||
leave_request LeaveRequests[] @relation("LeaveRequestEmployee")
|
||||
supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive")
|
||||
schedule_presets SchedulePresets[] @relation("SchedulePreset")
|
||||
|
||||
@@map("employees")
|
||||
}
|
||||
|
|
@ -59,10 +62,12 @@ model EmployeesArchive {
|
|||
id Int @id @default(autoincrement())
|
||||
employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id])
|
||||
employee_id Int
|
||||
archived_at DateTime @default(now())
|
||||
|
||||
user_id String @db.Uuid
|
||||
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
|
||||
last_name String
|
||||
job_title String?
|
||||
|
|
@ -71,8 +76,6 @@ model EmployeesArchive {
|
|||
company_code Int
|
||||
first_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")
|
||||
}
|
||||
|
|
@ -92,10 +95,10 @@ model CustomersArchive {
|
|||
id Int @id @default(autoincrement())
|
||||
customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id])
|
||||
customer_id Int
|
||||
archived_at DateTime @default(now())
|
||||
user_id String @db.Uuid
|
||||
user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id])
|
||||
user_id String @db.Uuid
|
||||
|
||||
archived_at DateTime @default(now())
|
||||
invoice_id Int? @unique
|
||||
|
||||
@@map("customers_archive")
|
||||
|
|
@ -105,16 +108,20 @@ model LeaveRequests {
|
|||
id Int @id @default(autoincrement())
|
||||
employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id])
|
||||
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
|
||||
leave_type LeaveTypes
|
||||
start_date_time DateTime @db.Date
|
||||
end_date_time DateTime? @db.Date
|
||||
|
||||
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)
|
||||
leave_type LeaveTypes
|
||||
|
||||
archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive")
|
||||
|
||||
@@unique([employee_id, leave_type, date], name: "leave_per_employee_date")
|
||||
@@index([employee_id, date])
|
||||
@@map("leave_requests")
|
||||
}
|
||||
|
||||
|
|
@ -122,14 +129,18 @@ model LeaveRequestsArchive {
|
|||
id Int @id @default(autoincrement())
|
||||
leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id])
|
||||
leave_request_id Int
|
||||
|
||||
archived_at DateTime @default(now())
|
||||
employee_id Int
|
||||
leave_type LeaveTypes
|
||||
start_date_time DateTime @db.Date
|
||||
end_date_time DateTime? @db.Date
|
||||
date DateTime @db.Date
|
||||
payable_hours Decimal? @db.Decimal(5, 2)
|
||||
requested_hours Decimal? @db.Decimal(5, 2)
|
||||
comment String
|
||||
leave_type LeaveTypes
|
||||
approval_status LeaveApprovalStatus
|
||||
|
||||
@@unique([leave_request_id])
|
||||
@@index([employee_id, date])
|
||||
@@map("leave_requests_archive")
|
||||
}
|
||||
|
||||
|
|
@ -137,10 +148,10 @@ model LeaveRequestsArchive {
|
|||
view PayPeriods {
|
||||
pay_year Int
|
||||
pay_period_no Int
|
||||
label String
|
||||
payday DateTime @db.Date
|
||||
period_start DateTime @db.Date
|
||||
period_end DateTime @db.Date
|
||||
label String
|
||||
|
||||
@@map("pay_period")
|
||||
}
|
||||
|
|
@ -149,12 +160,15 @@ model Timesheets {
|
|||
id Int @id @default(autoincrement())
|
||||
employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id])
|
||||
employee_id Int
|
||||
|
||||
start_date DateTime @db.Date
|
||||
is_approved Boolean @default(false)
|
||||
|
||||
shift Shifts[] @relation("ShiftTimesheet")
|
||||
expense Expenses[] @relation("ExpensesTimesheet")
|
||||
archive TimesheetsArchive[] @relation("TimesheetsToArchive")
|
||||
|
||||
@@unique([employee_id, start_date], name: "employee_id_start_date")
|
||||
@@map("timesheets")
|
||||
}
|
||||
|
||||
|
|
@ -162,24 +176,71 @@ model TimesheetsArchive {
|
|||
id Int @id @default(autoincrement())
|
||||
timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id])
|
||||
timesheet_id Int
|
||||
archive_at DateTime @default(now())
|
||||
|
||||
employee_id Int
|
||||
is_approved Boolean
|
||||
archive_at DateTime @default(now())
|
||||
|
||||
@@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 {
|
||||
id Int @id @default(autoincrement())
|
||||
timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id])
|
||||
timesheet_id Int
|
||||
bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id])
|
||||
bank_code_id Int
|
||||
description String?
|
||||
|
||||
date DateTime @db.Date
|
||||
start_time DateTime @db.Time(0)
|
||||
end_time DateTime @db.Time(0)
|
||||
is_approved Boolean @default(false)
|
||||
is_remote Boolean @default(false)
|
||||
comment String?
|
||||
|
||||
archive ShiftsArchive[] @relation("ShiftsToArchive")
|
||||
|
||||
|
|
@ -190,13 +251,14 @@ model ShiftsArchive {
|
|||
id Int @id @default(autoincrement())
|
||||
shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id])
|
||||
shift_id Int
|
||||
archive_at DateTime @default(now())
|
||||
timesheet_id Int
|
||||
bank_code_id Int
|
||||
description String?
|
||||
|
||||
date DateTime @db.Date
|
||||
start_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")
|
||||
}
|
||||
|
|
@ -211,6 +273,7 @@ model BankCodes {
|
|||
shifts Shifts[] @relation("ShiftBankCodes")
|
||||
expenses Expenses[] @relation("ExpenseBankCodes")
|
||||
leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes")
|
||||
SchedulePresetShifts SchedulePresetShifts[] @relation("SchedulePresetShiftsBankCodes")
|
||||
|
||||
@@map("bank_codes")
|
||||
}
|
||||
|
|
@ -221,12 +284,15 @@ model Expenses {
|
|||
timesheet_id Int
|
||||
bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id])
|
||||
bank_code_id Int
|
||||
attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull)
|
||||
attachment Int?
|
||||
|
||||
date DateTime @db.Date
|
||||
amount Decimal @db.Money
|
||||
attachement String?
|
||||
description String?
|
||||
is_approved Boolean @default(false)
|
||||
mileage Decimal? @db.Decimal(12,2)
|
||||
comment String
|
||||
supervisor_comment String?
|
||||
is_approved Boolean @default(false)
|
||||
|
||||
archive ExpensesArchive[] @relation("ExpensesToArchive")
|
||||
|
||||
|
|
@ -237,13 +303,16 @@ model ExpensesArchive {
|
|||
id Int @id @default(autoincrement())
|
||||
expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id])
|
||||
expense_id Int
|
||||
attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull)
|
||||
attachment Int?
|
||||
|
||||
timesheet_id Int
|
||||
archived_at DateTime @default(now())
|
||||
bank_code_id Int
|
||||
date DateTime @db.Date
|
||||
amount Decimal @db.Money
|
||||
attachement String?
|
||||
description String?
|
||||
amount Decimal? @db.Money
|
||||
mileage Decimal? @db.Decimal(12,2)
|
||||
comment String?
|
||||
is_approved Boolean
|
||||
supervisor_comment String?
|
||||
|
||||
|
|
@ -283,8 +352,9 @@ model Blobs {
|
|||
|
||||
model Attachments {
|
||||
id Int @id @default(autoincrement())
|
||||
sha256 String @db.Char(64)
|
||||
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_id String //expense_id, employee_id, etc
|
||||
original_name String
|
||||
|
|
@ -293,6 +363,9 @@ model Attachments {
|
|||
created_by String
|
||||
created_at DateTime @default(now())
|
||||
|
||||
expenses Expenses[] @relation("ExpenseAttachment")
|
||||
expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment")
|
||||
|
||||
AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment")
|
||||
|
||||
@@index([owner_type, owner_id, created_at])
|
||||
|
|
@ -315,6 +388,19 @@ model AttachmentVariants {
|
|||
@@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 {
|
||||
ACTIVE
|
||||
DELETED
|
||||
|
|
@ -347,6 +433,7 @@ enum LeaveTypes {
|
|||
PARENTAL // maternite/paternite/adoption
|
||||
LEGAL // obligations legales comme devoir de juree
|
||||
WEDDING // mariage
|
||||
HOLIDAY // férier
|
||||
|
||||
@@map("leave_types")
|
||||
}
|
||||
|
|
@ -360,3 +447,13 @@ enum LeaveApprovalStatus {
|
|||
|
||||
@@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 { AppService } from './app.service';
|
||||
import { ArchivalModule } from './modules/archival/archival.module';
|
||||
import { AuthenticationModule } from './modules/authentication/auth.module';
|
||||
import { BankCodesModule } from './modules/bank-codes/bank-codes.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 { EmployeesModule } from './modules/employees/employees.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 { OvertimeService } from './modules/business-logics/services/overtime.service';
|
||||
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
|
||||
import { PreferencesModule } from './modules/preferences/preferences.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ShiftsModule } from './modules/shifts/shifts.module';
|
||||
import { TimesheetsModule } from './modules/timesheets/timesheets.module';
|
||||
import { UsersModule } from './modules/users-management/users.module';
|
||||
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({
|
||||
imports: [
|
||||
|
|
@ -30,7 +35,7 @@ import { ConfigModule } from '@nestjs/config';
|
|||
BankCodesModule,
|
||||
BusinessLogicsModule,
|
||||
ConfigModule.forRoot({isGlobal: true}),
|
||||
CsvExportModule,
|
||||
// CsvExportModule,
|
||||
CustomersModule,
|
||||
EmployeesModule,
|
||||
ExpensesModule,
|
||||
|
|
@ -39,13 +44,38 @@ import { ConfigModule } from '@nestjs/config';
|
|||
NotificationsModule,
|
||||
OauthSessionsModule,
|
||||
PayperiodsModule,
|
||||
PreferencesModule,
|
||||
PrismaModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ScheduleModule.forRoot(), //cronjobs
|
||||
SchedulePresetsModule,
|
||||
ShiftsModule,
|
||||
TimesheetsModule,
|
||||
UsersModule,
|
||||
],
|
||||
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 {}
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
// export function toDateOnly(day: Date): Date {
|
||||
// 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 { AppModule } from './app.module';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
import { OwnershipGuard } from './common/guards/ownership.guard';
|
||||
|
|
@ -25,8 +24,6 @@ async function bootstrap() {
|
|||
|
||||
const reflector = app.get(Reflector); //setup Reflector for Roles()
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }));
|
||||
app.useGlobalGuards(
|
||||
// new JwtAuthGuard(reflector), //Authentification JWT
|
||||
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { EmployeesModule } from "../employees/employees.module";
|
|||
LeaveRequestsArchiveController,
|
||||
ShiftsArchiveController,
|
||||
TimesheetsArchiveController,
|
||||
]
|
||||
],
|
||||
})
|
||||
|
||||
export class ArchivalModule {}
|
||||
|
|
@ -2,20 +2,20 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr
|
|||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||
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')
|
||||
// @UseGuards()
|
||||
@Controller('archives/employees')
|
||||
export class EmployeesArchiveController {
|
||||
constructor(private readonly employeesService: EmployeesService) {}
|
||||
constructor(private readonly employeesArchiveService: EmployeesArchivalService) {}
|
||||
|
||||
@Get()
|
||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'List of archived employees'})
|
||||
@ApiResponse({ status: 200, description: 'List of archived employees', isArray: true })
|
||||
async findAllArchived(): Promise<EmployeesArchive[]> {
|
||||
return this.employeesService.findAllArchived();
|
||||
return this.employeesArchiveService.findAllArchived();
|
||||
}
|
||||
|
||||
@Get()
|
||||
|
|
@ -24,7 +24,7 @@ export class EmployeesArchiveController {
|
|||
@ApiResponse({ status: 200, description: 'Archived employee found'})
|
||||
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<EmployeesArchive> {
|
||||
try{
|
||||
return await this.employeesService.findOneArchived(id);
|
||||
return await this.employeesArchiveService.findOneArchived(id);
|
||||
}catch {
|
||||
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 { ExpensesArchive,Roles as RoleEnum } from "@prisma/client";
|
||||
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')
|
||||
// @UseGuards()
|
||||
@Controller('archives/expenses')
|
||||
export class ExpensesArchiveController {
|
||||
constructor(private readonly expensesService: ExpensesQueryService) {}
|
||||
constructor(private readonly expensesService: ExpensesArchivalService) {}
|
||||
|
||||
@Get()
|
||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
|
|
|
|||
|
|
@ -1,33 +1,7 @@
|
|||
import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, 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";
|
||||
import { Controller } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('LeaveRequests Archives')
|
||||
// @UseGuards()
|
||||
@Controller('archives/leaveRequests')
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
export class LeaveRequestsArchiveController {}
|
||||
|
|
@ -2,13 +2,13 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr
|
|||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client";
|
||||
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')
|
||||
// @UseGuards()
|
||||
@Controller('archives/shifts')
|
||||
export class ShiftsArchiveController {
|
||||
constructor(private readonly shiftsService:ShiftsQueryService) {}
|
||||
constructor(private readonly shiftsService: ShiftsArchivalService) {}
|
||||
|
||||
@Get()
|
||||
//@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 { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||
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')
|
||||
// @UseGuards()
|
||||
@Controller('archives/timesheets')
|
||||
export class TimesheetsArchiveController {
|
||||
constructor(private readonly timesheetsService: TimesheetsQueryService) {}
|
||||
constructor(private readonly timesheetsService: TimesheetArchiveService) {}
|
||||
|
||||
@Get()
|
||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service";
|
||||
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service";
|
||||
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service";
|
||||
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service";
|
||||
import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service";
|
||||
import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service";
|
||||
import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service";
|
||||
|
||||
@Injectable()
|
||||
export class ArchivalService {
|
||||
private readonly logger = new Logger(ArchivalService.name);
|
||||
|
||||
constructor(
|
||||
private readonly timesheetsService: TimesheetsQueryService,
|
||||
private readonly expensesService: ExpensesQueryService,
|
||||
private readonly shiftsService: ShiftsQueryService,
|
||||
private readonly leaveRequestsService: LeaveRequestsService,
|
||||
private readonly timesheetsService: TimesheetArchiveService,
|
||||
private readonly expensesService: ExpensesArchivalService,
|
||||
private readonly shiftsService: ShiftsArchivalService,
|
||||
) {}
|
||||
|
||||
@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.expensesService.archiveOld();
|
||||
await this.shiftsService.archiveOld();
|
||||
await this.leaveRequestsService.archiveExpired();
|
||||
// await this.leaveRequestsService.archiveExpired();
|
||||
this.logger.log('archivation process done');
|
||||
} catch (err) {
|
||||
this.logger.error('an error occured during archivation process ', err);
|
||||
|
|
|
|||
|
|
@ -7,40 +7,43 @@ import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOperation, ApiResponse }
|
|||
@Controller('bank-codes')
|
||||
export class BankCodesControllers {
|
||||
constructor(private readonly bankCodesService: BankCodesService) {}
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new bank code' })
|
||||
@ApiResponse({ status: 201, description: 'Bank code successfully created.' })
|
||||
@ApiBadRequestResponse({ description: 'Invalid input data.' })
|
||||
create(@Body() dto: CreateBankCodeDto) {
|
||||
return this.bankCodesService.create(dto);
|
||||
}
|
||||
// @Post()
|
||||
// @ApiOperation({ summary: 'Create a new bank code' })
|
||||
// @ApiResponse({ status: 201, description: 'Bank code successfully created.' })
|
||||
// @ApiBadRequestResponse({ description: 'Invalid input data.' })
|
||||
// create(@Body() dto: CreateBankCodeDto) {
|
||||
// return this.bankCodesService.create(dto);
|
||||
// }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Retrieve all bank codes' })
|
||||
@ApiResponse({ status: 200, description: 'List of bank codes.' })
|
||||
findAll() {
|
||||
return this.bankCodesService.findAll();
|
||||
}
|
||||
// @Get()
|
||||
// @ApiOperation({ summary: 'Retrieve all bank codes' })
|
||||
// @ApiResponse({ status: 200, description: 'List of bank codes.' })
|
||||
// findAll() {
|
||||
// return this.bankCodesService.findAll();
|
||||
// }
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Retrieve a bank code by its ID' })
|
||||
@ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number){
|
||||
return this.bankCodesService.findOne(id);
|
||||
}
|
||||
// @Get(':id')
|
||||
// @ApiOperation({ summary: 'Retrieve a bank code by its ID' })
|
||||
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||
// findOne(@Param('id', ParseIntPipe) id: number){
|
||||
// return this.bankCodesService.findOne(id);
|
||||
// }
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update an existing bank code' })
|
||||
@ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) {
|
||||
return this.bankCodesService.update(id, dto)
|
||||
}
|
||||
// @Patch(':id')
|
||||
// @ApiOperation({ summary: 'Update an existing bank code' })
|
||||
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) {
|
||||
// return this.bankCodesService.update(id, dto)
|
||||
// }
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a bank code' })
|
||||
@ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.bankCodesService.remove(id);
|
||||
}
|
||||
// @Delete(':id')
|
||||
// @ApiOperation({ summary: 'Delete a bank code' })
|
||||
// @ApiNotFoundResponse({ description: 'Bank code not found.' })
|
||||
// remove(@Param('id', ParseIntPipe) id: number) {
|
||||
// return this.bankCodesService.remove(id);
|
||||
// }
|
||||
}
|
||||
|
|
@ -1,6 +1,15 @@
|
|||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaService } from "../../../prisma/prisma.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
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()
|
||||
export class HolidayService {
|
||||
|
|
@ -8,35 +17,63 @@ export class HolidayService {
|
|||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
//switch employeeId for email
|
||||
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> {
|
||||
//sets the end of the window to 1ms before the week with the holiday
|
||||
const holiday_week_start = getWeekStart(holiday_date);
|
||||
const window_end = new Date(holiday_week_start.getTime() - 1);
|
||||
//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 )
|
||||
//fetch employee_id by email
|
||||
private async resolveEmployeeByEmail(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;
|
||||
}
|
||||
|
||||
const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700'];
|
||||
//fetches all shift of the employee in said window ( 4 previous completed weeks )
|
||||
private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise<number> {
|
||||
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({
|
||||
where: { timesheet: { employee_id: employee_id } ,
|
||||
where: {
|
||||
timesheet: { employee_id: employee_id },
|
||||
date: { gte: window_start, lte: window_end },
|
||||
bank_code: { bank_code: { in: valid_codes } },
|
||||
},
|
||||
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 daily_hours = total_hours / 20;
|
||||
|
||||
return daily_hours;
|
||||
const hours_by_week = new Map<number, number>();
|
||||
for(const shift of shifts) {
|
||||
const hours = computeHours(shift.start_time, shift.end_time);
|
||||
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
|
||||
async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise<number> {
|
||||
const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date);
|
||||
const daily_rate = Math.min(hours, 8);
|
||||
this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`);
|
||||
let capped_total = 0;
|
||||
for(let offset = 1; offset <= 4; offset++) {
|
||||
const week_start = new Date(holiday_week_start.getTime() - offset * WEEK_IN_MS);
|
||||
const key = week_start.getTime();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +1,152 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class OvertimeService {
|
||||
|
||||
private logger = new Logger(OvertimeService.name);
|
||||
private daily_max = 12; // maximum for regular hours per day
|
||||
private weekly_max = 80; //maximum for regular hours per week
|
||||
private daily_max = 8; // maximum for regular hours per day
|
||||
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) {}
|
||||
|
||||
//calculate Daily overtime
|
||||
getDailyOvertimeHours(start: Date, end: Date): number {
|
||||
const hours = computeHours(start, end, 5);
|
||||
const overtime = Math.max(0, hours - this.daily_max);
|
||||
this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`);
|
||||
//calculate daily overtime
|
||||
async getDailyOvertimeHoursForDay(employee_id: number, date: Date): Promise<number> {
|
||||
const shifts = await this.prisma.shifts.findMany({
|
||||
where: { date: date, timesheet: { employee_id: employee_id } },
|
||||
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;
|
||||
}
|
||||
|
||||
//calculate Weekly overtime
|
||||
//switch employeeId for email
|
||||
async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise<number> {
|
||||
const week_start = getWeekStart(refDate);
|
||||
async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise<number> {
|
||||
const week_start = getWeekStart(ref_date);
|
||||
const week_end = getWeekEnd(week_start);
|
||||
|
||||
//fetches all shifts containing hours
|
||||
const shifts = await this.prisma.shifts.findMany({
|
||||
where: { timesheet: { employee_id: employeeId, shift: {
|
||||
every: {date: { gte: week_start, lte: week_end } }
|
||||
},
|
||||
},
|
||||
//fetches all shifts from INCLUDED_TYPES array
|
||||
const included_shifts = await this.prisma.shifts.findMany({
|
||||
where: {
|
||||
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 },
|
||||
orderBy: [{date: 'asc'}, {start_time:'asc'}],
|
||||
});
|
||||
|
||||
//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);
|
||||
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;
|
||||
}
|
||||
|
||||
//transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift
|
||||
async transformRegularHoursToWeeklyOvertime(
|
||||
employee_id: number,
|
||||
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;
|
||||
|
||||
//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})`);
|
||||
// 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;
|
||||
}
|
||||
// return pay;
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaService } from "../../../prisma/prisma.service";
|
||||
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
|
||||
|
||||
@Injectable()
|
||||
export class SickLeaveService {
|
||||
|
|
@ -9,8 +9,17 @@ export class SickLeaveService {
|
|||
private readonly logger = new Logger(SickLeaveService.name);
|
||||
|
||||
//switch employeeId for email
|
||||
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number):
|
||||
Promise<number> {
|
||||
async calculateSickLeavePay(
|
||||
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
|
||||
const period_start = getYearStart(reference_date);
|
||||
const period_end = reference_date;
|
||||
|
|
@ -26,11 +35,12 @@ export class SickLeaveService {
|
|||
|
||||
//count the amount of worked days
|
||||
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;
|
||||
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()}
|
||||
-> ${period_end.toDateString()}`);
|
||||
this.logger.debug(
|
||||
`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`,
|
||||
);
|
||||
|
||||
//less than 30 worked days returns 0
|
||||
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
|
||||
|
||||
//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);
|
||||
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
|
||||
(period_end.getMonth() - first_bonus_date.getMonth()) + 1;
|
||||
const first_bonus_date = new Date(
|
||||
threshold_date.getFullYear(),
|
||||
threshold_date.getMonth() + 1,
|
||||
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;
|
||||
|
||||
//cap of 10 days
|
||||
if (acquired_days > 10) acquired_days = 10;
|
||||
|
||||
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()}
|
||||
, bonusMonths = ${months}, acquired Days = ${acquired_days}`);
|
||||
this.logger.debug(
|
||||
`Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`,
|
||||
);
|
||||
|
||||
const payable_days = Math.min(acquired_days, days_requested);
|
||||
const raw_hours = payable_days * 8 * modifier;
|
||||
const rounded = roundToQuarterHour(raw_hours)
|
||||
this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`);
|
||||
const raw_hours = payable_days * hours_per_day * modifier;
|
||||
const rounded = roundToQuarterHour(raw_hours);
|
||||
this.logger.debug(
|
||||
`Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`,
|
||||
);
|
||||
return rounded;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,15 +6,7 @@ export class VacationService {
|
|||
constructor(private readonly prisma: PrismaService) {}
|
||||
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
|
||||
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
|
||||
//fetch hiring date
|
||||
|
|
|
|||
|
|
@ -14,51 +14,55 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
|
|||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Post()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR)
|
||||
@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);
|
||||
}
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
@Get()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'Find all customers' })
|
||||
@ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true })
|
||||
@ApiResponse({ status: 400, description: 'List of customers not found' })
|
||||
findAll(): Promise<Customers[]> {
|
||||
return this.customersService.findAll();
|
||||
}
|
||||
// @Post()
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR)
|
||||
// @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(':id')
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'Find customer' })
|
||||
@ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto })
|
||||
@ApiResponse({ status: 400, description: 'Customer not found' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number): Promise<Customers> {
|
||||
return this.customersService.findOne(id);
|
||||
}
|
||||
// @Get()
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Find all customers' })
|
||||
// @ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true })
|
||||
// @ApiResponse({ status: 400, description: 'List of customers not found' })
|
||||
// findAll(): Promise<Customers[]> {
|
||||
// return this.customersService.findAll();
|
||||
// }
|
||||
|
||||
@Patch(':id')
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'Update customer' })
|
||||
@ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto })
|
||||
@ApiResponse({ status: 400, description: 'Customer not found' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateCustomerDto,
|
||||
): Promise<Customers> {
|
||||
return this.customersService.update(id, dto);
|
||||
}
|
||||
// @Get(':id')
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Find customer' })
|
||||
// @ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto })
|
||||
// @ApiResponse({ status: 400, description: 'Customer not found' })
|
||||
// findOne(@Param('id', ParseIntPipe) id: number): Promise<Customers> {
|
||||
// return this.customersService.findOne(id);
|
||||
// }
|
||||
|
||||
@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);
|
||||
}
|
||||
// @Patch(':id')
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Update customer' })
|
||||
// @ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto })
|
||||
// @ApiResponse({ status: 400, description: 'Customer not found' })
|
||||
// update(
|
||||
// @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',
|
||||
description: 'Customer`s phone number',
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
phone_number: number;
|
||||
@IsString()
|
||||
phone_number: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ',
|
||||
|
|
|
|||
|
|
@ -1,92 +1,93 @@
|
|||
import { Injectable, NotFoundException } 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';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(dto: CreateCustomerDto): Promise<Customers> {
|
||||
const {
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone_number,
|
||||
residence,
|
||||
invoice_id,
|
||||
} = dto;
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
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[]> {
|
||||
return this.prisma.customers.findMany({
|
||||
include: { user: true },
|
||||
})
|
||||
}
|
||||
|
||||
async findOne(id:number): Promise<Customers> {
|
||||
const customer = await this.prisma.customers.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
if(!customer) throw new NotFoundException(`Customer #${id} not found`);
|
||||
return customer;
|
||||
}
|
||||
|
||||
async update(id: number,dto: UpdateCustomerDto): Promise<Customers> {
|
||||
const customer = await this.findOne(id);
|
||||
|
||||
const {
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone_number,
|
||||
residence,
|
||||
invoice_id,
|
||||
} = dto;
|
||||
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
await transaction.users.update({
|
||||
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 }});
|
||||
}
|
||||
// constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// async create(dto: CreateCustomerDto): Promise<Customers> {
|
||||
// const {
|
||||
// first_name,
|
||||
// last_name,
|
||||
// email,
|
||||
// phone_number,
|
||||
// residence,
|
||||
// invoice_id,
|
||||
// } = 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.customers.create({
|
||||
// data: {
|
||||
// user_id: user.id,
|
||||
// invoice_id,
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// findAll(): Promise<Customers[]> {
|
||||
// return this.prisma.customers.findMany({
|
||||
// include: { user: true },
|
||||
// })
|
||||
// }
|
||||
|
||||
// async findOne(id:number): Promise<Customers> {
|
||||
// const customer = await this.prisma.customers.findUnique({
|
||||
// where: { id },
|
||||
// include: { user: true },
|
||||
// });
|
||||
// if(!customer) throw new NotFoundException(`Customer #${id} not found`);
|
||||
// return customer;
|
||||
// }
|
||||
|
||||
// async update(id: number,dto: UpdateCustomerDto): Promise<Customers> {
|
||||
// const customer = await this.findOne(id);
|
||||
|
||||
// const {
|
||||
// first_name,
|
||||
// last_name,
|
||||
// email,
|
||||
// phone_number,
|
||||
// residence,
|
||||
// invoice_id,
|
||||
// } = dto;
|
||||
|
||||
// return this.prisma.$transaction(async (transaction) => {
|
||||
// await transaction.users.update({
|
||||
// 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 { Employees, Roles as RoleEnum } from '@prisma/client';
|
||||
import { Body,Controller,Get,NotFoundException,Param,Patch } from '@nestjs/common';
|
||||
import { EmployeesService } from '../services/employees.service';
|
||||
import { CreateEmployeeDto } from '../dtos/create-employee.dto';
|
||||
import { UpdateEmployeeDto } from '../dtos/update-employee.dto';
|
||||
import { RolesAllowed } from '../../../common/decorators/roles.decorators';
|
||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { EmployeeListItemDto } from '../dtos/list-employee.dto';
|
||||
import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
|
||||
import { EmployeesArchivalService } from '../services/employees-archival.service';
|
||||
|
||||
@ApiTags('Employees')
|
||||
@ApiBearerAuth('access-token')
|
||||
// @UseGuards()
|
||||
@Controller('employees')
|
||||
export class EmployeesController {
|
||||
constructor(private readonly employeesService: EmployeesService) {}
|
||||
|
||||
@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();
|
||||
}
|
||||
constructor(
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly archiveService: EmployeesArchivalService,
|
||||
) {}
|
||||
|
||||
@Get('employee-list')
|
||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
|
||||
|
|
@ -42,34 +26,6 @@ export class EmployeesController {
|
|||
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')
|
||||
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiBearerAuth('access-token')
|
||||
|
|
@ -82,10 +38,61 @@ export class EmployeesController {
|
|||
// if last_work_day is set => archive the employee
|
||||
// else if employee is archived and first_work_day or last_work_day = null => restore
|
||||
//otherwise => standard update
|
||||
const result = await this.employeesService.patchEmployee(email, dto);
|
||||
const result = await this.archiveService.patchEmployee(email, dto);
|
||||
if(!result) {
|
||||
throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`)
|
||||
}
|
||||
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',
|
||||
description: 'Employee`s phone number',
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
phone_number: number;
|
||||
@IsString()
|
||||
phone_number: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export class EmployeeProfileItemDto {
|
|||
company_name: number | null;
|
||||
job_title: string | null;
|
||||
email: string | null;
|
||||
phone_number: number;
|
||||
phone_number: string;
|
||||
first_work_day: string;
|
||||
last_work_day?: string | null;
|
||||
residence: string | null;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,6 @@ export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) {
|
|||
@IsOptional()
|
||||
supervisor_id?: number;
|
||||
|
||||
@Max(2147483647)
|
||||
phone_number: number;
|
||||
@IsOptional()
|
||||
phone_number: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EmployeesController } from './controllers/employees.controller';
|
||||
import { EmployeesService } from './services/employees.service';
|
||||
import { EmployeesArchivalService } from './services/employees-archival.service';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
@Module({
|
||||
controllers: [EmployeesController],
|
||||
providers: [EmployeesService],
|
||||
exports: [EmployeesService],
|
||||
controllers: [EmployeesController, SharedModule],
|
||||
providers: [EmployeesService, EmployeesArchivalService],
|
||||
exports: [EmployeesService, EmployeesArchivalService],
|
||||
})
|
||||
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,70 +1,12 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
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 { 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()
|
||||
export class EmployeesService {
|
||||
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[]> {
|
||||
return this.prisma.employees.findMany({
|
||||
select: {
|
||||
|
|
@ -91,28 +33,15 @@ export class EmployeesService {
|
|||
}).then(rows => rows.map(r => ({
|
||||
first_name: r.user.first_name,
|
||||
last_name: r.user.last_name,
|
||||
employee_full_name: `${r.user.first_name} ${r.user.last_name}`,
|
||||
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,
|
||||
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> {
|
||||
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({
|
||||
where: { user: { email } },
|
||||
|
|
@ -147,255 +76,155 @@ export class EmployeesService {
|
|||
return {
|
||||
first_name: emp.user.first_name,
|
||||
last_name: emp.user.last_name,
|
||||
employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`,
|
||||
email: emp.user.email,
|
||||
residence: emp.user.residence,
|
||||
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,
|
||||
job_title: emp.job_title,
|
||||
employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`,
|
||||
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,
|
||||
dto: UpdateEmployeeDto,
|
||||
): Promise<Employees> {
|
||||
const emp = await this.findOne(email);
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
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;
|
||||
// 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) => {
|
||||
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 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
// findAll(): Promise<Employees[]> {
|
||||
// return this.prisma.employees.findMany({
|
||||
// include: { user: true },
|
||||
// });
|
||||
// }
|
||||
|
||||
// async findOne(email: string): Promise<Employees> {
|
||||
// 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 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) => {
|
||||
await transaction.employees.updateMany({
|
||||
where: { supervisor_id: emp.id },
|
||||
data: { supervisor_id: null },
|
||||
});
|
||||
const deleted_employee = await transaction.employees.delete({
|
||||
where: {id: emp.id },
|
||||
});
|
||||
await transaction.users.delete({
|
||||
where: { id: emp.user_id },
|
||||
});
|
||||
return deleted_employee;
|
||||
});
|
||||
}
|
||||
// return this.prisma.$transaction(async (transaction) => {
|
||||
// await transaction.employees.updateMany({
|
||||
// where: { supervisor_id: emp.id },
|
||||
// data: { supervisor_id: null },
|
||||
// });
|
||||
// const deleted_employee = await transaction.employees.delete({
|
||||
// where: {id: emp.id },
|
||||
// });
|
||||
// await transaction.users.delete({
|
||||
// where: { id: emp.user_id },
|
||||
// });
|
||||
// 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 { ExpensesQueryService } from "../services/expenses-query.service";
|
||||
import { CreateExpenseDto } from "../dtos/create-expense.dto";
|
||||
import { Expenses } from "@prisma/client";
|
||||
import { Body, Controller, Get, Param, Put, } from "@nestjs/common";
|
||||
import { Roles as RoleEnum } from '.prisma/client';
|
||||
import { UpdateExpenseDto } from "../dtos/update-expense.dto";
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||
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')
|
||||
@ApiBearerAuth('access-token')
|
||||
|
|
@ -15,60 +14,82 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto";
|
|||
@Controller('Expenses')
|
||||
export class ExpensesController {
|
||||
constructor(
|
||||
private readonly expensesService: ExpensesQueryService,
|
||||
private readonly expensesApprovalService: ExpensesCommandService,
|
||||
private readonly query: ExpensesQueryService,
|
||||
private readonly command: ExpensesCommandService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'Create expense' })
|
||||
@ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
|
||||
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||
create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
|
||||
return this.expensesService.create(dto);
|
||||
@Put('upsert/:email/:date')
|
||||
async upsert_by_date(
|
||||
@Param('email') email: string,
|
||||
@Param('date') date: string,
|
||||
@Body() dto: UpsertExpenseDto,
|
||||
): Promise<UpsertExpenseResult> {
|
||||
return this.command.upsertExpensesByDate(email, date, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'Find all expenses' })
|
||||
@ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
|
||||
@ApiResponse({ status: 400, description: 'List of expenses not found' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||
return this.expensesService.findAll(filters);
|
||||
@Get('list/:email/:year/:period_no')
|
||||
async findExpenseListByPayPeriodAndEmail(
|
||||
@Param('email') email:string,
|
||||
@Param('year') year: number,
|
||||
@Param('period_no') period_no: number,
|
||||
): Promise<DayExpensesDto> {
|
||||
return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@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);
|
||||
}
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
@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.expensesService.update(id,dto);
|
||||
}
|
||||
// @Post()
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Create expense' })
|
||||
// @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
|
||||
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||
// create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
|
||||
// return this.query.create(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.expensesService.remove(id);
|
||||
}
|
||||
// @Get()
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Find all expenses' })
|
||||
// @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
|
||||
// @ApiResponse({ status: 400, description: 'List of expenses not found' })
|
||||
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
// findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||
// return this.query.findAll(filters);
|
||||
// }
|
||||
|
||||
@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.expensesApprovalService.updateApproval(id, isApproved);
|
||||
}
|
||||
// @Get(':id')
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @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.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'
|
||||
})
|
||||
@IsString()
|
||||
description?: string;
|
||||
comment: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'DENIED, APPROUVED, PENDING, etc...',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export class SearchExpensesDto {
|
|||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description_contains?: string;
|
||||
comment_contains?: string;
|
||||
|
||||
@IsOptional()
|
||||
@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 { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
||||
import { ExpensesCommandService } from "./services/expenses-command.service";
|
||||
import { ExpensesArchivalService } from "./services/expenses-archival.service";
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
|
||||
@Module({
|
||||
imports: [BusinessLogicsModule],
|
||||
imports: [BusinessLogicsModule, SharedModule],
|
||||
controllers: [ExpensesController],
|
||||
providers: [ExpensesQueryService, ExpensesCommandService],
|
||||
exports: [ ExpensesQueryService ],
|
||||
providers: [
|
||||
ExpensesQueryService,
|
||||
ExpensesArchivalService,
|
||||
ExpensesCommandService,
|
||||
],
|
||||
exports: [
|
||||
ExpensesQueryService,
|
||||
ExpensesArchivalService,
|
||||
],
|
||||
})
|
||||
|
||||
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 { Expenses, Prisma } from "@prisma/client";
|
||||
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()
|
||||
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() {
|
||||
return this.prisma.expenses;
|
||||
|
|
@ -23,16 +48,203 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
|||
);
|
||||
}
|
||||
|
||||
// deprecated since batch transaction are made with timesheets
|
||||
// async updateManyWithTx(
|
||||
// tx: Prisma.TransactionClient,
|
||||
// ids: number[],
|
||||
// isApproved: boolean,
|
||||
// ): Promise<number> {
|
||||
// const { count } = await tx.expenses.updateMany({
|
||||
// where: { id: { in: ids } },
|
||||
// data: { is_approved: isApproved },
|
||||
// });
|
||||
// return count;
|
||||
// }
|
||||
//_____________________________________________________________________________________________
|
||||
// MASTER CRUD FUNCTION
|
||||
//_____________________________________________________________________________________________
|
||||
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
|
||||
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
|
||||
|
||||
//validates if there is an existing expense, at least 1 old or new
|
||||
const { old_expense, new_expense } = dto ?? {};
|
||||
if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
|
||||
|
||||
//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 { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateExpenseDto } from "../dtos/create-expense.dto";
|
||||
import { Expenses, ExpensesArchive } from "@prisma/client";
|
||||
import { UpdateExpenseDto } from "../dtos/update-expense.dto";
|
||||
import { MileageService } from "src/modules/business-logics/services/mileage.service";
|
||||
import { SearchExpensesDto } from "../dtos/search-expense.dto";
|
||||
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
|
||||
import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
|
||||
import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers";
|
||||
import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types";
|
||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||
|
||||
@Injectable()
|
||||
export class ExpensesQueryService {
|
||||
constructor(
|
||||
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
|
||||
const bank_code = await this.prisma.bankCodes.findUnique({
|
||||
where: { id: bank_code_id },
|
||||
select: { type: true, modifier: true },
|
||||
//fetch pay-period using year and period_no
|
||||
const pay_period = await this.prisma.payPeriods.findFirst({
|
||||
where: {
|
||||
pay_year: year,
|
||||
pay_period_no: period_no
|
||||
},
|
||||
select: { period_start: true, period_end: true },
|
||||
});
|
||||
if(!bank_code) {
|
||||
throw new NotFoundException(`bank_code #${bank_code_id} not found`)
|
||||
}
|
||||
if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} 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);
|
||||
const start = toUTCDateOnly(pay_period.period_start);
|
||||
const end = toUTCDateOnly(pay_period.period_end);
|
||||
|
||||
//sets rows data
|
||||
const rows = await this.prisma.expenses.findMany({
|
||||
where: {
|
||||
date: { gte: start, lte: end },
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
|
||||
//declare return values
|
||||
const expenses: ExpenseDto[] = [];
|
||||
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 {
|
||||
final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
|
||||
total_amount += amount;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||
const where = buildPrismaWhere(filters);
|
||||
const expenses = await this.prisma.expenses.findMany({ where })
|
||||
return expenses;
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Expenses> {
|
||||
const expense = await this.prisma.expenses.findUnique({
|
||||
where: { id },
|
||||
include: { timesheet: { include: { employee: { include: { user:true } } } },
|
||||
bank_code: true,
|
||||
},
|
||||
});
|
||||
if (!expense) {
|
||||
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,
|
||||
description, 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 }),
|
||||
...(description !== undefined && { description }),
|
||||
...(is_approved !== undefined && { is_approved }),
|
||||
...(supervisor_comment !== undefined && { supervisor_comment }),
|
||||
},
|
||||
include: { timesheet: { include: { employee: { include: { user: true } } } },
|
||||
bank_code: true,
|
||||
},
|
||||
//fills rows array
|
||||
expenses.push({
|
||||
type,
|
||||
amount,
|
||||
mileage,
|
||||
comment: row.comment ?? '',
|
||||
is_approved: row.is_approved ?? false,
|
||||
supervisor_comment: row.supervisor_comment ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<Expenses> {
|
||||
await this.findOne(id);
|
||||
return this.prisma.expenses.delete({ where: { id } });
|
||||
return {
|
||||
expenses,
|
||||
total_expense: round2(total_amount),
|
||||
total_mileage: round2(total_mileage),
|
||||
};
|
||||
}
|
||||
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
//archivation functions ******************************************************
|
||||
// async create(dto: CreateExpenseDto): Promise<Expenses> {
|
||||
// const { timesheet_id, bank_code_id, date, amount:rawAmount,
|
||||
// comment, is_approved,supervisor_comment} = dto;
|
||||
// //fetches type and modifier
|
||||
// const bank_code = await this.prisma.bankCodes.findUnique({
|
||||
// where: { id: bank_code_id },
|
||||
// select: { type: true, modifier: true },
|
||||
// });
|
||||
// if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
|
||||
|
||||
async archiveOld(): Promise<void> {
|
||||
//fetches archived timesheet's Ids
|
||||
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
|
||||
select: { timesheet_id: true },
|
||||
});
|
||||
// //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,
|
||||
// comment,
|
||||
// is_approved,
|
||||
// supervisor_comment
|
||||
// },
|
||||
// include: { timesheet: { include: { employee: { include: { user: true }}}},
|
||||
// bank_code: true,
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
|
||||
// async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||
// const where = buildPrismaWhere(filters);
|
||||
// const expenses = await this.prisma.expenses.findMany({ where })
|
||||
// return expenses;
|
||||
// }
|
||||
|
||||
// async findOne(id: number): Promise<Expenses> {
|
||||
// const expense = await this.prisma.expenses.findUnique({
|
||||
// where: { id },
|
||||
// include: { timesheet: { include: { employee: { include: { user:true } } } },
|
||||
// bank_code: true,
|
||||
// },
|
||||
// });
|
||||
// if (!expense) {
|
||||
// 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 } });
|
||||
// }
|
||||
|
||||
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,
|
||||
attachement: exp.attachement,
|
||||
description: exp.description,
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { Roles as RoleEnum } from '.prisma/client';
|
||||
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 { ExportCsvOptionsDto } from "../dtos/export-csv-options.dto";
|
||||
|
||||
|
||||
@Controller('exports')
|
||||
|
|
@ -13,33 +14,27 @@ export class CsvExportController {
|
|||
|
||||
@Get('csv')
|
||||
@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)
|
||||
async exportCsv(@Query() options: ExportCsvOptionsDto,
|
||||
@Query('period') periodId: string ): Promise<Buffer> {
|
||||
|
||||
//sets default values
|
||||
const companies = options.companies && options.companies.length ? options.companies :
|
||||
[ ExportCompany.TARGO, ExportCompany.SOLUCOM];
|
||||
const types = options.type && options.type.length ? options.type :
|
||||
Object.values(ExportType);
|
||||
|
||||
//collects all
|
||||
const all = await this.csvService.collectTransaction(Number(periodId), companies);
|
||||
|
||||
//filters by type
|
||||
const filtered = all.filter(r => {
|
||||
switch (r.bank_code.toLocaleLowerCase()) {
|
||||
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);
|
||||
async exportCsv(@Query() query: ExportCsvOptionsDto ): Promise<Buffer> {
|
||||
const rows = await this.csvService.collectTransaction(
|
||||
query.year,
|
||||
query.period_no,
|
||||
{
|
||||
approved: query.approved ?? true,
|
||||
types: {
|
||||
shifts: query.shifts ?? true,
|
||||
expenses: query.expenses ?? true,
|
||||
holiday: query.holiday ?? true,
|
||||
vacation: query.vacation ?? true,
|
||||
},
|
||||
companies: {
|
||||
targo: query.targo ?? true,
|
||||
solucom: query.solucom ?? true,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
//generating the csv file
|
||||
return this.csvService.generateCsv(filtered);
|
||||
);
|
||||
return this.csvService.generateCsv(rows);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { Module } from "@nestjs/common";
|
||||
import { CsvExportController } from "./controllers/csv-exports.controller";
|
||||
import { CsvExportService } from "./services/csv-exports.service";
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
|
||||
@Module({
|
||||
providers:[CsvExportService],
|
||||
providers:[CsvExportService, SharedModule],
|
||||
controllers: [CsvExportController],
|
||||
})
|
||||
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 {
|
||||
SHIFTS = 'Quart de travail',
|
||||
EXPENSES = 'Depenses',
|
||||
HOLIDAY = 'Ferie',
|
||||
VACATION = 'Vacance',
|
||||
SICK_LEAVE = 'Absence'
|
||||
}
|
||||
|
||||
export enum ExportCompany {
|
||||
TARGO = 'Targo',
|
||||
SOLUCOM = 'Solucom',
|
||||
function toBoolean(v: any) {
|
||||
if(typeof v === 'boolean') return v;
|
||||
if(typeof v === 'string') return ['true', '1', 'on','yes'].includes(v.toLowerCase());
|
||||
return false;
|
||||
}
|
||||
|
||||
export class ExportCsvOptionsDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(ExportCompany, { each: true })
|
||||
companies?: ExportCompany[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(ExportType, { each: true })
|
||||
type?: ExportType[];
|
||||
@Transform(({ value }) => parseInt(value,10))
|
||||
@IsInt() @Min(2023)
|
||||
year! : number;
|
||||
|
||||
@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 { ExportCompany } from "../dtos/export-csv-options.dto";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
export interface CsvRow {
|
||||
company_code: number;
|
||||
|
|
@ -14,148 +13,240 @@ export interface CsvRow {
|
|||
holiday_date?: string;
|
||||
}
|
||||
|
||||
type Filters = {
|
||||
types: {
|
||||
shifts: boolean;
|
||||
expenses: boolean;
|
||||
holiday: boolean;
|
||||
vacation: boolean;
|
||||
};
|
||||
companies: {
|
||||
targo: boolean;
|
||||
solucom: boolean;
|
||||
};
|
||||
approved: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CsvExportService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true):
|
||||
Promise<CsvRow[]> {
|
||||
|
||||
const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2);
|
||||
|
||||
async collectTransaction(
|
||||
year: number,
|
||||
period_no: number,
|
||||
filters: Filters,
|
||||
approved: boolean = true
|
||||
): Promise<CsvRow[]> {
|
||||
//fetch period
|
||||
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 end_date = period.period_end;
|
||||
const start = period.period_start;
|
||||
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
|
||||
const shifts = await this.prisma.shifts.findMany({
|
||||
//Flag types
|
||||
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: {
|
||||
date: { gte: start_date, lte: end_date },
|
||||
date: { gte: start, lte: end },
|
||||
...approved_filter,
|
||||
timesheet: {
|
||||
employee: { company_code: { in: company_codes} } },
|
||||
bank_code: { bank_code: { notIn: [ holiday_code, vacation_code ] } },
|
||||
timesheet: { employee: { company_code: { in: company_codes } } },
|
||||
},
|
||||
include: {
|
||||
bank_code: true,
|
||||
timesheet: { include: {
|
||||
employee: { include: {
|
||||
user:true,
|
||||
supervisor: { include: {
|
||||
user:true } } } } } },
|
||||
select: {
|
||||
date: true,
|
||||
start_time: true,
|
||||
end_time: 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([]));
|
||||
}
|
||||
|
||||
//fetching expenses
|
||||
const expenses = await this.prisma.expenses.findMany({
|
||||
if(want_holiday) {
|
||||
promises.push( this.prisma.shifts.findMany({
|
||||
where: {
|
||||
date: { gte: start_date, lte: end_date },
|
||||
date: { gte: start, lte: end },
|
||||
...approved_filter,
|
||||
bank_code: { bank_code: holiday_code },
|
||||
timesheet: { employee: { company_code: { in: company_codes } } },
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
start_time: true,
|
||||
end_time: 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([]));
|
||||
}
|
||||
|
||||
if(want_vacation) {
|
||||
promises.push( this.prisma.shifts.findMany({
|
||||
where: {
|
||||
date: { gte: start, lte: end },
|
||||
...approved_filter,
|
||||
bank_code: { bank_code: vacation_code },
|
||||
timesheet: { employee: { company_code: { in: company_codes } } },
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
start_time: true,
|
||||
end_time: 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([]));
|
||||
}
|
||||
|
||||
if(want_expense) {
|
||||
promises.push( this.prisma.expenses.findMany({
|
||||
where: {
|
||||
date: { gte: start, lte: end },
|
||||
...approved_filter,
|
||||
timesheet: { employee: { company_code: { in: company_codes } } },
|
||||
},
|
||||
include: { bank_code: true,
|
||||
timesheet: { include: {
|
||||
employee: { include: {
|
||||
user: true,
|
||||
supervisor: { include: {
|
||||
user:true } } } } } },
|
||||
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 } },
|
||||
}},
|
||||
}},
|
||||
},
|
||||
});
|
||||
|
||||
//fetching leave requests
|
||||
const leaves = await this.prisma.leaveRequests.findMany({
|
||||
where : {
|
||||
start_date_time: { gte: start_date, lte: end_date },
|
||||
employee: { company_code: { in: company_codes } },
|
||||
},
|
||||
include: {
|
||||
bank_code: true,
|
||||
employee: { include: {
|
||||
user: true,
|
||||
supervisor: { include: {
|
||||
user: 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[] = [];
|
||||
|
||||
//Shifts Mapping
|
||||
for (const shift of shifts) {
|
||||
const emp = shift.timesheet.employee;
|
||||
const week_number = this.computeWeekNumber(start_date, shift.date);
|
||||
const hours = this.computeHours(shift.start_time, shift.end_time);
|
||||
|
||||
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: shift.bank_code.bank_code,
|
||||
quantity_hours: hours,
|
||||
const map_shifts = (shift: any, is_holiday: boolean) => {
|
||||
const employee = shift.timesheet.employee;
|
||||
const week = this.computeWeekNumber(start, shift.date);
|
||||
return {
|
||||
company_code: employee.company_code,
|
||||
external_payroll_id: employee.external_payroll_id,
|
||||
full_name: `${employee.first_name} ${ employee.last_name}`,
|
||||
bank_code: shift.bank_code?.bank_code ?? '',
|
||||
quantity_hours: this.computeHours(shift.start_time, shift.end_time),
|
||||
amount: undefined,
|
||||
week_number,
|
||||
pay_date: this.formatDate(end_date),
|
||||
holiday_date: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
//Expenses Mapping
|
||||
for (const e of expenses) {
|
||||
const emp = e.timesheet.employee;
|
||||
const week_number = this.computeWeekNumber(start_date, e.date);
|
||||
week_number: week,
|
||||
pay_date: this.formatDate(end),
|
||||
holiday_date: is_holiday? this.formatDate(shift.date) : '',
|
||||
} as CsvRow;
|
||||
};
|
||||
//final mapping of all shifts based filters
|
||||
for (const shift of base_shifts) rows.push(map_shifts(shift, false));
|
||||
for (const shift of holiday_shifts) rows.push(map_shifts(shift, true ));
|
||||
for (const shift of vacation_shifts) rows.push(map_shifts(shift, false));
|
||||
|
||||
for (const expense of expenses) {
|
||||
const employee = expense.timesheet.employee;
|
||||
const week = this.computeWeekNumber(start, expense.date);
|
||||
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: e.bank_code.bank_code,
|
||||
company_code: employee.company_code,
|
||||
external_payroll_id: employee.external_payroll_id,
|
||||
full_name: `${employee.first_name} ${ employee.last_name}`,
|
||||
bank_code: expense.bank_code?.bank_code ?? '',
|
||||
quantity_hours: undefined,
|
||||
amount: Number(e.amount),
|
||||
week_number,
|
||||
pay_date: this.formatDate(end_date),
|
||||
holiday_date: undefined,
|
||||
});
|
||||
amount: Number(expense.amount),
|
||||
week_number: week,
|
||||
pay_date: this.formatDate(end),
|
||||
holiday_date: '',
|
||||
})
|
||||
}
|
||||
|
||||
//Leaves Mapping
|
||||
for(const l of leaves) {
|
||||
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) => {
|
||||
//Final mapping and sorts
|
||||
rows.sort((a,b) => {
|
||||
if(a.external_payroll_id !== b.external_payroll_id) {
|
||||
return a.external_payroll_id - b.external_payroll_id;
|
||||
}
|
||||
if(a.bank_code !== b.bank_code) {
|
||||
return a.bank_code.localeCompare(b.bank_code);
|
||||
}
|
||||
return a.week_number - b.week_number;
|
||||
const bk_code = String(a.bank_code).localeCompare(String(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 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 {
|
||||
const header = [
|
||||
'company_code',
|
||||
|
|
@ -169,17 +260,22 @@ export class CsvExportService {
|
|||
'holiday_date',
|
||||
].join(',') + '\n';
|
||||
|
||||
const body = rows.map(r => [
|
||||
r.company_code,
|
||||
r.external_payroll_id,
|
||||
`${r.full_name.replace(/"/g, '""')}"`,
|
||||
r.bank_code,
|
||||
r.quantity_hours?.toFixed(2) ?? '',
|
||||
r.week_number,
|
||||
r.pay_date,
|
||||
r.holiday_date ?? '',
|
||||
].join(',')).join('\n');
|
||||
|
||||
const body = rows.map(row => {
|
||||
const full_name = `${String(row.full_name).replace(/"/g, '""')}`;
|
||||
const quantity_hours = (typeof row.quantity_hours === 'number') ? row.quantity_hours.toFixed(2) : '';
|
||||
const amount = (typeof row.amount === 'number') ? row.amount.toFixed(2) : '';
|
||||
return [
|
||||
row.company_code,
|
||||
row.external_payroll_id,
|
||||
full_name,
|
||||
row.bank_code,
|
||||
quantity_hours,
|
||||
amount,
|
||||
row.week_number,
|
||||
row.pay_date,
|
||||
row.holiday_date ?? '',
|
||||
].join(',');
|
||||
}).join('\n');
|
||||
return Buffer.from('\uFEFF' + header + body, 'utf8');
|
||||
}
|
||||
|
||||
|
|
@ -190,9 +286,13 @@ export class CsvExportService {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
toUTC(date: Date) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
}
|
||||
|
||||
private formatDate(d:Date): string {
|
||||
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 { LeaveRequestsService } from "../services/leave-requests.service";
|
||||
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
|
||||
import { LeaveRequests } from "@prisma/client";
|
||||
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto";
|
||||
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";
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { LeaveRequestsService } from "../services/leave-request.service";
|
||||
import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
|
||||
import { LeaveTypes } from "@prisma/client";
|
||||
|
||||
@ApiTags('Leave Requests')
|
||||
@ApiBearerAuth('access-token')
|
||||
// @UseGuards()
|
||||
@Controller('leave-requests')
|
||||
export class LeaveRequestController {
|
||||
constructor(private readonly leaveRequetsService: LeaveRequestsService){}
|
||||
constructor(private readonly leave_service: LeaveRequestsService){}
|
||||
|
||||
@Post()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({summary: 'Create leave request' })
|
||||
@ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto })
|
||||
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||
create(@Body() dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
|
||||
return this. leaveRequetsService.create(dto);
|
||||
}
|
||||
@Post('upsert')
|
||||
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
|
||||
const { action, leave_requests } = await this.leave_service.handle(dto);
|
||||
return { action, leave_requests };
|
||||
}q
|
||||
|
||||
@Get()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({summary: 'Find all leave request' })
|
||||
@ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true })
|
||||
@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);
|
||||
}
|
||||
//TODO:
|
||||
/*
|
||||
@Get('archive')
|
||||
findAllArchived(){...}
|
||||
|
||||
//remove emp_id and use email
|
||||
@Delete(':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 });
|
||||
}
|
||||
@Get('archive/:id')
|
||||
findOneArchived(id){...}
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
export class LeaveRequestViewDto {
|
||||
id!: number;
|
||||
id: number;
|
||||
leave_type!: LeaveTypes;
|
||||
start_date_time!: string;
|
||||
end_date_time!: string | null;
|
||||
comment!: string | null;
|
||||
date!: string;
|
||||
comment!: string;
|
||||
approval_status: LeaveApprovalStatus;
|
||||
email!: string;
|
||||
employee_full_name: string;
|
||||
days_requested?: number;
|
||||
employee_full_name!: string;
|
||||
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 { LeaveRequestsService } from "./services/leave-requests.service";
|
||||
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
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({
|
||||
imports: [BusinessLogicsModule],
|
||||
imports: [BusinessLogicsModule, ShiftsModule, SharedModule],
|
||||
controllers: [LeaveRequestController],
|
||||
providers: [LeaveRequestsService],
|
||||
exports: [LeaveRequestsService],
|
||||
providers: [
|
||||
VacationLeaveRequestsService,
|
||||
SickLeaveRequestsService,
|
||||
HolidayLeaveRequestsService,
|
||||
LeaveRequestsService,
|
||||
PrismaService,
|
||||
LeaveRequestsUtils,
|
||||
],
|
||||
exports: [
|
||||
LeaveRequestsService,
|
||||
],
|
||||
})
|
||||
|
||||
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";
|
||||
|
||||
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 {
|
||||
const isoDate = row.date?.toISOString().slice(0, 10);
|
||||
if (!isoDate) {
|
||||
throw new Error(`Leave request #${row.id} has no date set.`);
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
leave_type: row.leave_type,
|
||||
start_date_time: toISO(row.start_date_time)!,
|
||||
end_date_time: toISO(row.end_date_time),
|
||||
date: isoDate,
|
||||
payable_hours: toNum(row.payable_hours),
|
||||
requested_hours: toNum(row.requested_hours),
|
||||
comment: row.comment,
|
||||
approval_status: row.approval_status,
|
||||
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";
|
||||
|
||||
function toISODateString(date:Date | null): string | null {
|
||||
return date ? date.toISOString().slice(0,10) : null;
|
||||
}
|
||||
const toNum = (value?: Prisma.Decimal | null) =>
|
||||
value !== null && value !== undefined ? Number(value) : undefined;
|
||||
|
||||
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 {
|
||||
id: row.id,
|
||||
leave_type: row.leave_type,
|
||||
start_date_time: toISODateString(row.start_date_time)!,
|
||||
end_date_time: toISODateString(row.end_date_time),
|
||||
date: iso_date,
|
||||
payable_hours: toNum(row.payable_hours),
|
||||
requested_hours: toNum(row.requested_hours),
|
||||
comment: row.comment,
|
||||
approval_status: row.approval_status,
|
||||
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 { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
|
||||
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||
import { LeaveRequestArchiveRow } from "./leave-requests-archive.select";
|
||||
import { LeaveRequestRow } from "./leave-requests.select";
|
||||
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
|
||||
import { mapArchiveRowToView } from '../mappers/leave-requests-archive.mapper';
|
||||
import { mapRowToView } from '../mappers/leave-requests.mapper';
|
||||
import { LeaveRequestArchiveRow } from './leave-requests-archive.select';
|
||||
import { LeaveRequestRow } from './leave-requests.select';
|
||||
|
||||
function toUTCDateOnly(date: Date): Date {
|
||||
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 */
|
||||
/** Active (table leave_requests) : proxy to base mapper */
|
||||
export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto {
|
||||
const view = mapRowToView(row);
|
||||
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
|
||||
return view;
|
||||
return mapRowToView(row);
|
||||
}
|
||||
|
||||
/** Archive (table leave_requests_archive) : map + days_requested */
|
||||
export function mapArchiveRowToViewWithDays(row: LeaveRequestArchiveRow, email: string, employee_full_name?: string):
|
||||
LeaveRequestViewDto {
|
||||
const view = mapArchiveRowToView(row, email, employee_full_name!);
|
||||
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
|
||||
return view;
|
||||
/** Archive (table leave_requests_archive) : proxy to base mapper */
|
||||
export function mapArchiveRowToViewWithDays(
|
||||
row: LeaveRequestArchiveRow,
|
||||
email: string,
|
||||
employee_full_name?: string,
|
||||
): 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,
|
||||
employee_id: true,
|
||||
leave_type: true,
|
||||
start_date_time: true,
|
||||
end_date_time: true,
|
||||
date: true,
|
||||
payable_hours: true,
|
||||
requested_hours: true,
|
||||
comment: true,
|
||||
approval_status: true,
|
||||
|
||||
} satisfies Prisma.LeaveRequestsArchiveSelect;
|
||||
|
||||
export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>;
|
||||
|
|
@ -5,8 +5,9 @@ export const leaveRequestsSelect = {
|
|||
id: true,
|
||||
bank_code_id: true,
|
||||
leave_type: true,
|
||||
start_date_time: true,
|
||||
end_date_time: true,
|
||||
date: true,
|
||||
payable_hours: true,
|
||||
requested_hours: true,
|
||||
comment: true,
|
||||
approval_status: true,
|
||||
employee: { select: {
|
||||
|
|
|
|||
|
|
@ -18,13 +18,6 @@ export class PayPeriodsController {
|
|||
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')
|
||||
@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'})
|
||||
|
|
@ -95,4 +88,16 @@ export class PayPeriodsController {
|
|||
): Promise<PayPeriodOverviewDto> {
|
||||
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();
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,15 +21,23 @@ export class EmployeePeriodOverviewDto {
|
|||
@ApiProperty({ example: 40, description: 'pay-period`s regular hours' })
|
||||
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;
|
||||
|
||||
@ApiProperty({ example: 0, description: 'pay-period`s emergency hours' })
|
||||
emergency_hours: number;
|
||||
|
||||
@ApiProperty({ example: 2, description: 'pay-period`s overtime hours' })
|
||||
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 ($)' })
|
||||
expenses: number;
|
||||
|
||||
|
|
@ -41,4 +49,6 @@ export class EmployeePeriodOverviewDto {
|
|||
description: 'Tous les timesheets de la période sont approuvés pour cet employé',
|
||||
})
|
||||
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 { ExpensesCommandService } from "../expenses/services/expenses-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({
|
||||
imports: [PrismaModule, TimesheetsModule],
|
||||
imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
|
||||
providers: [
|
||||
PayPeriodsQueryService,
|
||||
PayPeriodsCommandService,
|
||||
TimesheetsCommandService,
|
||||
ExpensesCommandService,
|
||||
ShiftsCommandService,
|
||||
PrismaService,
|
||||
],
|
||||
controllers: [PayPeriodsController],
|
||||
exports: [
|
||||
PayPeriodsQueryService,
|
||||
PayPeriodsCommandService,
|
||||
PayPeriodsQueryService,
|
||||
]
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -68,38 +68,4 @@ export class PayPeriodsCommandService {
|
|||
});
|
||||
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);
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ export class PayPeriodsQueryService {
|
|||
} as any);
|
||||
}
|
||||
|
||||
//find crew member associated with supervisor
|
||||
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
|
||||
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; }> = [];
|
||||
|
|
@ -69,6 +70,7 @@ export class PayPeriodsQueryService {
|
|||
return result;
|
||||
}
|
||||
|
||||
//fetchs crew emails
|
||||
async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise<Set<string>> {
|
||||
const crew = await this.resolveCrew(supervisor_id, include_subtree);
|
||||
return new Set(crew.map(crew_member => crew_member.email).filter(Boolean));
|
||||
|
|
@ -99,8 +101,10 @@ export class PayPeriodsQueryService {
|
|||
const seed_names = new Map<number, { name: string; email: string }>(
|
||||
crew.map(crew => [
|
||||
crew.id,
|
||||
{ name:`${crew.first_name} ${crew.last_name}`.trim(),
|
||||
email: crew.email }
|
||||
{
|
||||
name: `${crew.first_name} ${crew.last_name}`.trim(),
|
||||
email: crew.email
|
||||
}
|
||||
]
|
||||
)
|
||||
);
|
||||
|
|
@ -113,12 +117,15 @@ export class PayPeriodsQueryService {
|
|||
payday: period.payday,
|
||||
pay_year: period.pay_year,
|
||||
label: period.label,
|
||||
//add is_approved
|
||||
}, { filtered_employee_ids: crew_ids, seed_names });
|
||||
}
|
||||
|
||||
private async buildOverview(
|
||||
period: { period_start: string | Date; period_end: string | Date; payday: string | Date;
|
||||
period_no: number; pay_year: number; label: string; },
|
||||
period: {
|
||||
period_start: string | Date; period_end: string | Date; payday: string | Date;
|
||||
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> {
|
||||
const toDateString = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
|
@ -148,19 +155,25 @@ export class PayPeriodsQueryService {
|
|||
select: {
|
||||
start_time: true,
|
||||
end_time: true,
|
||||
timesheet: { select: {
|
||||
is_remote: true,
|
||||
timesheet: {
|
||||
select: {
|
||||
is_approved: true,
|
||||
employee: { select: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
user: { select: {
|
||||
user: {
|
||||
select: {
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
email: true,
|
||||
} },
|
||||
} },
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
bank_code: { select: { categorie: true } },
|
||||
},
|
||||
bank_code: { select: { categorie: true, type: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -172,18 +185,24 @@ export class PayPeriodsQueryService {
|
|||
},
|
||||
select: {
|
||||
amount: true,
|
||||
timesheet: { select: {
|
||||
timesheet: {
|
||||
select: {
|
||||
is_approved: true,
|
||||
employee: { select: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
user: { select: {
|
||||
user: {
|
||||
select: {
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
email: true,
|
||||
} },
|
||||
} },
|
||||
} },
|
||||
bank_code: { select: { categorie: true, modifier: true } },
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
bank_code: { select: { categorie: true, modifier: true, type: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -196,12 +215,19 @@ export class PayPeriodsQueryService {
|
|||
email,
|
||||
employee_name: name,
|
||||
regular_hours: 0,
|
||||
other_hours: {
|
||||
evening_hours: 0,
|
||||
emergency_hours: 0,
|
||||
overtime_hours: 0,
|
||||
sick_hours: 0,
|
||||
holiday_hours: 0,
|
||||
vacation_hours: 0,
|
||||
},
|
||||
total_hours: 0,
|
||||
expenses: 0,
|
||||
mileage: 0,
|
||||
is_approved: true,
|
||||
is_remote: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -212,12 +238,19 @@ export class PayPeriodsQueryService {
|
|||
email,
|
||||
employee_name: name,
|
||||
regular_hours: 0,
|
||||
other_hours: {
|
||||
evening_hours: 0,
|
||||
emergency_hours: 0,
|
||||
overtime_hours: 0,
|
||||
sick_hours: 0,
|
||||
holiday_hours: 0,
|
||||
vacation_hours: 0,
|
||||
},
|
||||
total_hours: 0,
|
||||
expenses: 0,
|
||||
mileage: 0,
|
||||
is_approved: true,
|
||||
is_remote: true,
|
||||
});
|
||||
}
|
||||
return by_employee.get(id)!;
|
||||
|
|
@ -229,15 +262,33 @@ export class PayPeriodsQueryService {
|
|||
const record = ensure(employee.id, name, employee.user.email);
|
||||
|
||||
const hours = computeHours(shift.start_time, shift.end_time);
|
||||
const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase();
|
||||
switch (categorie) {
|
||||
case "EVENING": record.evening_hours += hours; break;
|
||||
case "EMERGENCY":
|
||||
case "URGENT": record.emergency_hours += hours; break;
|
||||
case "OVERTIME": record.overtime_hours += hours; break;
|
||||
default: record.regular_hours += hours; break;
|
||||
const type = (shift.bank_code?.type ?? '').toUpperCase();
|
||||
switch (type) {
|
||||
case "EVENING": record.other_hours.evening_hours += hours;
|
||||
record.total_hours += hours;
|
||||
break;
|
||||
case "EMERGENCY": record.other_hours.emergency_hours += hours;
|
||||
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_remote = record.is_remote || !!shift.is_remote;
|
||||
}
|
||||
|
||||
for (const expense of expenses) {
|
||||
|
|
@ -248,10 +299,10 @@ export class PayPeriodsQueryService {
|
|||
const amount = toMoney(expense.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;
|
||||
if (categorie === "MILEAGE" && rate > 0) {
|
||||
record.mileage += amount / rate;
|
||||
if (type === "MILEAGE" && rate > 0) {
|
||||
record.mileage += Math.round((amount / rate) * 100) / 100;
|
||||
}
|
||||
record.is_approved = record.is_approved && expense.timesheet.is_approved;
|
||||
}
|
||||
|
|
@ -287,6 +338,7 @@ export class PayPeriodsQueryService {
|
|||
period_start: period.period_start,
|
||||
period_end: period.period_end,
|
||||
label: period.label,
|
||||
//add is_approved
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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