diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 004c28e6b..1a1a75275 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1328,10 +1328,9 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); - it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => { - // Export b→c holds every wagon of the border crossing: no further export - // can board anywhere (they all must ride that edge), so the window closes — - // while intercity keeps booking the free a→b leg through the per-leg budget. + it('is NOT full when the border edge is sold out but a home leg still has room', async () => { + // FULL is corridor-wide now: b→dj holds every wagon, but a→b is empty, so + // sub-corridor bookings can still sell that leg — the window stays open. const { service } = buildService({ physicalWagons: 2, routeStops: ['yard-a', 'yard-b', 'yard-dj'], @@ -1345,6 +1344,23 @@ describe('BookingBatchService — built-train wagon capacity', () => { reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }), ], }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('is FULL once every leg of the corridor is sold out', async () => { + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-b', 'yard-dj'], + yardCountries: { + 'yard-a': 'ETHIOPIA', + 'yard-b': 'ETHIOPIA', + 'yard-dj': 'DJIBOUTI', + }, + reserved: [ + reservedBooking('b1', { origin: 'yard-a', dest: 'yard-dj' }), + reservedBooking('b2', { origin: 'yard-a', dest: 'yard-dj' }), + ], + }); await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 7541e4bd1..5ce75abd2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -4594,14 +4594,12 @@ export class BookingBatchService implements OnModuleInit { } /** - * FULL is DIRECTIONAL: the schedule's trade direction is full when the - * border-crossing edge (which every export/import must ride) can't take one + * FULL is CORRIDOR-WIDE: the train is full only when NO leg can take one * more minimal wagon on any axis — slots for built trains (the consist is * the capacity, weight/length settled at build), all three axes otherwise * (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44). - * Home-side legs may still run empty; intercity ride-alongs keep filling - * them via the per-leg budget and never consult this flag. Domestic routes - * (no border) are full only when every edge is closed. + * A full DCT→Dire leg alone does NOT close the window while Dire→GMP still + * has room — sub-corridor bookings keep selling the open legs. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -4652,13 +4650,9 @@ export class BookingBatchService implements OnModuleInit { /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { - // "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and - // every import must cross the ET↔DJ border edge, so once that edge can't - // take one more minimal wagon the booking window may close — even while - // home-side legs still run empty. Intercity ride-alongs never consult this - // flag; they keep booking the free legs through the per-leg budget. - // A single-country (domestic) corridor has no mandatory edge, so it is - // full only when EVERY edge is closed on some axis. + // Full only when EVERY edge is closed on some axis: a full border edge + // still leaves the home-side legs bookable by sub-corridor cargo, so the + // window must stay open until not even the smallest wagon fits anywhere. const wagonDims = await this.loadWagonDims(); const physicalWagons = await this.builtTrainWagonCount(schedule); let limits: TrainLimits; @@ -4681,42 +4675,9 @@ export class BookingBatchService implements OnModuleInit { } const budget = await this.remainingBudget(schedule, limits, wagonDims); const minNeed = this.minPerWagonNeed(wagonDims); - const border = await this.borderLeg(budget.stops); - if (border) { - return !budget.fits( - { - wagons: 1, - weightTons: minNeed.grossWeightTons, - lengthMeters: minNeed.lengthMeters, - }, - border, - ); - } return budget.isExhausted(minNeed); } - /** - * The corridor's single border-crossing edge (last home-country stop → first - * far-country stop), or null when every stop is in one country. This is the - * edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it - * books — which makes it the train's directional fullness gauge. - */ - private async borderLeg(stops: string[]): Promise { - if (stops.length < 2) return null; - const yards = await this.dataSource - .getRepository(Yard) - .find({ where: { id: In(stops) } }); - const countryOf = new Map(yards.map((y) => [y.id, y.country])); - const first = countryOf.get(stops[0]); - if (!first) return null; - const crossIdx = stops.findIndex((id) => { - const country = countryOf.get(id); - return country != null && country !== first; - }); - if (crossIdx <= 0) return null; - return { fromEdge: crossIdx - 1, toEdge: crossIdx }; - } - /** * Smallest gross weight / shortest length one more wagon could add: the * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, @@ -4762,6 +4723,31 @@ export class BookingBatchService implements OnModuleInit { // nothing can board. if (await this.isTrainFull(schedule)) return; + // FULL concluded the cycle (phase DONE) and DONE rows are skipped by the + // window tick forever — so when wagons free up before departure, restart + // the cycle or nobody (customer or batch) can ever book the freed space. + // ponytail: reopens now and closes at departure; the office-hours clamp + // reapplies on the next conclude cycle. + const departure = schedule.scheduledDepartureDate; + if ( + schedule.windowPhase === "DONE" && + ["DRAFT", "SCHEDULED"].includes(schedule.status) && + departure && + departure.getTime() > Date.now() + ) { + await this.dataSource.getRepository(TrainSchedule).update(scheduleId, { + windowPhase: "PRE_WINDOW", + windowOpensAt: new Date(), + windowClosesAt: departure, + }); + await this.setWindow(scheduleId, "OPEN"); + this.logger.log( + `[BATCH] ${scheduleId} FULL cleared after wagons freed — window revived ` + + `(PRE_WINDOW, reopens immediately, closes at departure)`, + ); + return; + } + const customerWindowOpen = schedule.windowPhase == null || schedule.windowPhase === "OPEN"; await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED"); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 0648ac12c..53ed45fdb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2031,6 +2031,11 @@ export class TrainSchedulingService { }); }); + // Freed wagons may un-full the train — re-derive the window status (this + // also revives a DONE window pre-departure so the freed space is bookable + // again for import/export). + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + await this.trainCompositionRemovalLogRepository.create({ scheduleId, bookingId, diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 726b80f5e..568b3a47a 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -94,7 +94,7 @@ "react-intersection-observer": "^9.16.0", "react-pdf": "^10.4.1", "react-pdf-html": "^2.1.5", - "react-quill": "^2.0.0", + "react-quill-new": "^3.8.3", "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.27.0", "react-signature-canvas": "1.1.0-alpha.2", diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index f07f53b59..4a92695ca 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -19,8 +19,8 @@ import { import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowRight, ArrowRightLeft, Layers, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import ReactQuill from "react-quill"; -import "react-quill/dist/quill.snow.css"; +import ReactQuill from "react-quill-new"; +import "react-quill-new/dist/quill.snow.css"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98f4c0413..efefbf78e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,9 +469,9 @@ importers: react-pdf-html: specifier: ^2.1.5 version: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-quill: - specifier: ^2.0.0 - version: 2.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-quill-new: + specifier: ^3.8.3 + version: 3.8.3(quill-delta@5.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-resizable-panels: specifier: ^3.0.6 version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -4808,9 +4808,6 @@ packages: '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} - '@types/quill@1.3.10': - resolution: {integrity: sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==} - '@types/raf@3.4.3': resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} @@ -6569,10 +6566,6 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} - deep-equal@1.1.2: - resolution: {integrity: sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==} - engines: {node: '>= 0.4'} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -7096,9 +7089,6 @@ packages: eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} - eventemitter3@2.0.3: - resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==} - eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -7224,8 +7214,8 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.1.2: - resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} @@ -8809,12 +8799,18 @@ packages: resolution: {integrity: sha512-Cc7mowptFl7ug5he6Iuos7aGRd9xbwTfnx1ng4AX/7F4iqemPaXAIJDi13IBwQZrKgli9OPEYXm6uCKr7ynxUQ==} engines: {node: '>=22.0.0'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash._reinterpolate@3.0.0: resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==} lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -9388,10 +9384,6 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - object-is@1.1.6: - resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} - engines: {node: '>= 0.4'} - object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -9532,8 +9524,8 @@ packages: pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} - parchment@1.1.4: - resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==} + parchment@3.0.0: + resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==} parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -9971,12 +9963,13 @@ packages: queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} - quill-delta@3.6.3: - resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==} - engines: {node: '>=0.10'} + quill-delta@5.1.0: + resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} + engines: {node: '>= 12.0.0'} - quill@1.3.7: - resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==} + quill@2.0.3: + resolution: {integrity: sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==} + engines: {npm: '>=8.2.3'} radix-ui@1.5.0: resolution: {integrity: sha512-Nzh2HNpClgB31FBHRqt2xG8XNUfVfQRpf34hACC5PNrXTd5JdXdqOXwLs3BL+D8CNYiNQiJiT8QGr5Q4vq+00w==} @@ -10156,11 +10149,12 @@ packages: react: '>=16.8' react-dom: '>=16.8' - react-quill@2.0.0: - resolution: {integrity: sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==} + react-quill-new@3.8.3: + resolution: {integrity: sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==} peerDependencies: - react: ^16 || ^17 || ^18 - react-dom: ^16 || ^17 || ^18 + quill-delta: ^5.1.0 + react: ^16 || ^17 || ^18 || ^19 + react-dom: ^16 || ^17 || ^18 || ^19 react-redux@9.3.0: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} @@ -16943,10 +16937,6 @@ snapshots: '@types/qs@6.15.1': {} - '@types/quill@1.3.10': - dependencies: - parchment: 1.1.4 - '@types/raf@3.4.3': optional: true @@ -18864,15 +18854,6 @@ snapshots: deep-eql@5.0.2: {} - deep-equal@1.1.2: - dependencies: - is-arguments: 1.2.0 - is-date-object: 1.1.0 - is-regex: 1.2.1 - object-is: 1.1.6 - object-keys: 1.1.1 - regexp.prototype.flags: 1.5.4 - deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -19539,8 +19520,6 @@ snapshots: eventemitter2@6.4.9: {} - eventemitter3@2.0.3: {} - eventemitter3@4.0.7: {} eventemitter3@5.0.4: {} @@ -19781,7 +19760,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.1.2: {} + fast-diff@1.3.0: {} fast-equals@5.4.0: {} @@ -21611,10 +21590,14 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 + lodash-es@4.18.1: {} + lodash._reinterpolate@3.0.0: {} lodash.camelcase@4.3.0: {} + lodash.clonedeep@4.5.0: {} + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} @@ -22162,11 +22145,6 @@ snapshots: object-inspect@1.13.4: {} - object-is@1.1.6: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - object-keys@1.1.1: {} object-treeify@1.1.33: {} @@ -22349,7 +22327,7 @@ snapshots: pako@2.1.0: {} - parchment@1.1.4: {} + parchment@3.0.0: {} parent-module@1.0.1: dependencies: @@ -22759,20 +22737,18 @@ snapshots: dependencies: inherits: 2.0.4 - quill-delta@3.6.3: + quill-delta@5.1.0: dependencies: - deep-equal: 1.1.2 - extend: 3.0.2 - fast-diff: 1.1.2 + fast-diff: 1.3.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 - quill@1.3.7: + quill@2.0.3: dependencies: - clone: 2.1.2 - deep-equal: 1.1.2 - eventemitter3: 2.0.3 - extend: 3.0.2 - parchment: 1.1.4 - quill-delta: 3.6.3 + eventemitter3: 5.0.4 + lodash-es: 4.18.1 + parchment: 3.0.0 + quill-delta: 5.1.0 radix-ui@1.5.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -23097,11 +23073,11 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - react-quill@2.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + react-quill-new@3.8.3(quill-delta@5.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@types/quill': 1.3.10 - lodash: 4.18.1 - quill: 1.3.7 + lodash-es: 4.18.1 + quill: 2.0.3 + quill-delta: 5.1.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6)