File

src/orders/orders.service.ts

Description

Service responsible for the full order lifecycle within a tenant.

Provides CRUD operations, state machine transitions, geofence-validated loading confirmation, missing-field waiver workflow, and vehicle assignment with weight capacity checks.

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

Async assignVehicle
assignVehicle(orderId: string, vehicleId: string, organizationId: string, userId: string)

Manually assign a vehicle to an order. Creates a trip if needed.

Parameters :
Name Type Optional
orderId string No
vehicleId string No
organizationId string No
userId string No
Returns : unknown
Async bulkCreate
bulkCreate(organizationId: string, orders: CreateOrderDto[], userId?: string)

Create multiple orders sequentially. Each order is individually validated and assigned an order number.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
orders CreateOrderDto[] No
  • Array of order creation payloads.
userId string Yes
  • ID of the user creating the orders.
Returns : unknown

Array of created orders.

Async bulkSendToJobs
bulkSendToJobs(organizationId: string, userId: string)

Bulk send all eligible orders to Jobs. Only orders with 0 missing fields get sent. The rest are rejected with reasons.

Parameters :
Name Type Optional
organizationId string No
userId string No
Returns : unknown
Async confirmLoading
confirmLoading(id: string, userId: string, organizationId: string, latitude?: number, longitude?: number)

Confirm that an order has been physically loaded onto a vehicle.

Enforces GPS geofence validation: the driver must be within the loading bay's configured radius (default 50m) to confirm. This prevents remote confirmation fraud.

Side effects on success:

  • Order status set to LOADED, loadingConfirmed set to true
  • GPS coordinates and confirmer name appended to specialInstructions
  • If all orders on the trip are loaded, the bay is released to AVAILABLE
  • A BayEvent (LOADING_COMPLETE) is recorded for bay analytics
Parameters :
Name Type Optional Description
id string No
  • The order's database ID.
userId string No
  • ID of the user confirming (for audit trail).
organizationId string No
  • The tenant's organisation ID.
latitude number Yes
  • Driver's current GPS latitude (required).
longitude number Yes
  • Driver's current GPS longitude (required).
Returns : unknown

The updated order.

Async create
create(organizationId: string, dto: CreateOrderDto, userId?: string)

Create a new order within the given organisation.

Automatically detects missing required fields (origin, destination, commodity, weight/pieces, delivery date) and marks the order as INCOMPLETE if any are absent. This drives the review/waiver UI in the web platform.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
dto CreateOrderDto No
  • Order creation payload.
userId string Yes
  • ID of the user creating the order (for audit trail).
Returns : unknown

The created order with client relation included.

Async findAll
findAll(organizationId: string, filters: OrderFilterDto)

List orders with pagination, filtering, and full-text search.

Supports filtering by status, priority, source, clientId, date range, and a free-text search across orderNumber, referenceNumber, originCity, destCity, and commodity fields.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
filters OrderFilterDto No
  • Pagination, sorting, and filter parameters.
Returns : unknown

Paginated result with data array and meta pagination info.

Async findOne
findOne(organizationId: string, id: string)

Org-scoped lookup. findFirst with organizationId in the where clause makes cross-tenant ID-guessing physically impossible.

Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async getJobs
getJobs(organizationId: string)

Retrieve orders ready for the Jobs/Tasks planner board.

Returns orders with jobStatus of VALIDATED or READY, which are the states eligible for trip allocation by the planner.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
Returns : unknown

Object with data array and total count.

Async getStats
getStats(organizationId: string)

Get order count statistics grouped by status for the dashboard KPIs.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
Returns : unknown

Object with total count and byStatus breakdown.

Async remove
remove(organizationId: string, id: string)

Delete an order. Blocked if the order is on an active trip (ASSIGNED, LOADED, or IN_TRANSIT) to prevent silent data loss on the dispatch board.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
id string No
  • The order's database ID.
Returns : unknown

{ deleted: true } on success.

Async sendToJobs
sendToJobs(organizationId: string, orderId: string, userId: string)

Send order to Jobs — only if all missing fields are filled or waived.

Parameters :
Name Type Optional
organizationId string No
orderId string No
userId string No
Returns : unknown
Async update
update(organizationId: string, id: string, dto: UpdateOrderDto, userId?: string, role?: string)

Update an existing order.

Handles status transitions via the order state machine, event sourcing for audit trail, and driver self-service guards that restrict which fields a DRIVER role can modify (only status, jobStatus, loadingConfirmed, specialInstructions on their own trips).

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
id string No
  • The order's database ID.
dto UpdateOrderDto No
  • Partial update payload.
userId string Yes
  • ID of the user performing the update.
role string Yes
  • The caller's role (used for driver self-service guard).
Returns : unknown

The updated order with client relation.

Async updateJobStatus
updateJobStatus(organizationId: string, id: string, newJobStatus: string, userId?: string)

Advance an order's job status through the planner workflow.

Valid transitions: PENDING -> VALIDATED -> READY -> ALLOCATED.

Parameters :
Name Type Optional Description
organizationId string No
  • The tenant's organisation ID.
id string No
  • The order's database ID.
newJobStatus string No
  • The target job status.
userId string Yes
  • ID of the user (for audit trail).
Returns : unknown

The updated order with client relation.

Async waiveField
waiveField(organizationId: string, orderId: string, field: string, userId: string, userName: string, reason?: string)

Waive a missing field — mark as intentionally skipped with who/when/why.

Parameters :
Name Type Optional
organizationId string No
orderId string No
field string No
userId string No
userName string No
reason string Yes
Returns : unknown
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderDto } from './dto/update-order.dto';
import { OrderFilterDto } from './dto/order-filter.dto';
import {
  buildPaginationQuery,
  buildPaginationMeta,
} from '../common/utils/pagination.util';
import { assertOrderStatusTransition } from '../common/state-machines';

/**
 * Service responsible for the full order lifecycle within a tenant.
 *
 * Provides CRUD operations, state machine transitions, geofence-validated
 * loading confirmation, missing-field waiver workflow, and vehicle assignment
 * with weight capacity checks.
 *
 * @dependencies
 *   - {@link PrismaService} — tenant-aware database access (RLS-proxied)
 */
@Injectable()
export class OrdersService {
  constructor(private prisma: PrismaService) {}

  /**
   * Create a new order within the given organisation.
   *
   * Automatically detects missing required fields (origin, destination,
   * commodity, weight/pieces, delivery date) and marks the order as
   * `INCOMPLETE` if any are absent. This drives the review/waiver UI
   * in the web platform.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param dto - Order creation payload.
   * @param userId - ID of the user creating the order (for audit trail).
   * @returns The created order with client relation included.
   */
  async create(organizationId: string, dto: CreateOrderDto, userId?: string) {
    const orderNumber = await this.generateOrderNumber(organizationId);

    // Compute missing fields so the order enters the review workflow
    // with the waive tab and Send to Tasks button visible
    const missingFields: string[] = [];
    if (!dto.originAddress && !dto.originName) missingFields.push('originAddress');
    if (!dto.destAddress && !dto.destName) missingFields.push('destAddress');
    if (!dto.commodity) missingFields.push('commodity');
    if (!dto.weight && !dto.pieces && !dto.pallets) missingFields.push('weight/pieces');
    if (!dto.deliveryDate) missingFields.push('deliveryDate');

    const order = await this.prisma.order.create({
      data: {
        organizationId,
        clientId: dto.clientId,
        orderNumber,
        referenceNumber: dto.referenceNumber,
        priority: dto.priority || 'NORMAL',
        vehicleType: dto.vehicleType,
        originName: dto.originName,
        originAddress: dto.originAddress,
        originCity: dto.originCity,
        originState: dto.originState,
        originZip: dto.originZip,
        originLat: dto.originLat,
        originLng: dto.originLng,
        destName: dto.destName,
        destAddress: dto.destAddress,
        destCity: dto.destCity,
        destState: dto.destState,
        destZip: dto.destZip,
        destLat: dto.destLat,
        destLng: dto.destLng,
        pickupDate: dto.pickupDate ? new Date(dto.pickupDate) : undefined,
        deliveryDate: dto.deliveryDate ? new Date(dto.deliveryDate) : undefined,
        commodity: dto.commodity,
        weight: dto.weight,
        volume: dto.volume,
        pieces: dto.pieces,
        pallets: dto.pallets,
        specialInstructions: dto.specialInstructions,
        quoteAmount: dto.quoteAmount,
        lpoNumber: dto.lpoNumber,
        plantName: dto.plantName,
        distributorName: dto.distributorName,
        laneId: dto.laneId,
        status: 'PENDING_VALIDATION',
        jobStatus: missingFields.length > 0 ? 'INCOMPLETE' : 'IMPORTED',
        missingFields: missingFields,
      },
      include: { client: true },
    });

    // Create initial order event
    await this.prisma.orderEvent.create({
      data: {
        orderId: order.id,
        toStatus: 'DRAFT',
        userId,
        reason: 'Order created',
      },
    });

    return order;
  }

  /**
   * Create multiple orders sequentially. Each order is individually validated
   * and assigned an order number.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param orders - Array of order creation payloads.
   * @param userId - ID of the user creating the orders.
   * @returns Array of created orders.
   */
  async bulkCreate(
    organizationId: string,
    orders: CreateOrderDto[],
    userId?: string,
  ) {
    const results = [];
    for (const dto of orders) {
      const order = await this.create(organizationId, dto, userId);
      results.push(order);
    }
    return results;
  }

  /**
   * List orders with pagination, filtering, and full-text search.
   *
   * Supports filtering by status, priority, source, clientId, date range,
   * and a free-text search across orderNumber, referenceNumber, originCity,
   * destCity, and commodity fields.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param filters - Pagination, sorting, and filter parameters.
   * @returns Paginated result with `data` array and `meta` pagination info.
   */
  async findAll(organizationId: string, filters: OrderFilterDto) {
    const { skip, take, orderBy, page, limit } = buildPaginationQuery(filters);

    const where: Prisma.OrderWhereInput = { organizationId };

    if (filters.status) where.status = filters.status;
    if (filters.priority) where.priority = filters.priority;
    if (filters.source) where.source = filters.source;
    if (filters.clientId) where.clientId = filters.clientId;

    if (filters.dateFrom || filters.dateTo) {
      where.createdAt = {};
      if (filters.dateFrom) where.createdAt.gte = new Date(filters.dateFrom);
      if (filters.dateTo) where.createdAt.lte = new Date(filters.dateTo);
    }

    if (filters.search) {
      where.OR = [
        { orderNumber: { contains: filters.search, mode: 'insensitive' } },
        { referenceNumber: { contains: filters.search, mode: 'insensitive' } },
        { originCity: { contains: filters.search, mode: 'insensitive' } },
        { destCity: { contains: filters.search, mode: 'insensitive' } },
        { commodity: { contains: filters.search, mode: 'insensitive' } },
      ];
    }

    const [items, total] = await Promise.all([
      this.prisma.order.findMany({
        where,
        skip,
        take,
        orderBy,
        include: {
          client: { select: { id: true, name: true, code: true } },
          trip: {
            select: {
              id: true,
              tripNumber: true,
              status: true,
              vehicle: { select: { id: true, unitNumber: true, licensePlate: true, status: true } },
              driver: { select: { id: true, firstName: true, lastName: true, phone: true } },
            },
          },
        },
      }),
      this.prisma.order.count({ where }),
    ]);

    return { data: items, meta: buildPaginationMeta(total, page, limit) };
  }

  /**
   * Org-scoped lookup. findFirst with organizationId in the where clause
   * makes cross-tenant ID-guessing physically impossible.
   */
  async findOne(organizationId: string, id: string) {
    const order = await this.prisma.order.findFirst({
      where: { id, organizationId },
      include: {
        client: true,
        trip: true,
        events: { orderBy: { timestamp: 'desc' } },
        documents: true,
      },
    });
    if (!order) throw new NotFoundException('Order not found');
    return order;
  }

  /**
   * Update an existing order.
   *
   * Handles status transitions via the order state machine, event sourcing
   * for audit trail, and driver self-service guards that restrict which
   * fields a DRIVER role can modify (only status, jobStatus,
   * loadingConfirmed, specialInstructions on their own trips).
   *
   * @param organizationId - The tenant's organisation ID.
   * @param id - The order's database ID.
   * @param dto - Partial update payload.
   * @param userId - ID of the user performing the update.
   * @param role - The caller's role (used for driver self-service guard).
   * @returns The updated order with client relation.
   * @throws {BadRequestException} Invalid status transition, driver permission violation, or order not on a trip.
   * @throws {NotFoundException} Order not found in this organisation.
   */
  async update(organizationId: string, id: string, dto: UpdateOrderDto, userId?: string, role?: string) {
    const existing = await this.findOne(organizationId, id);

    // ── Driver self-service guard ─────────────────────────────────────
    // When a DRIVER calls this endpoint, two extra rules apply:
    //   1. The order must be on a trip whose driverId matches a Driver
    //      record linked to this user.
    //   2. They can only change a small whitelist: status, jobStatus,
    //      loadingConfirmed, specialInstructions.
    if (role === 'DRIVER') {
      const driverAllowedKeys = new Set([
        'status',
        'jobStatus',
        'loadingConfirmed',
        'specialInstructions',
      ]);
      const tooMany = Object.keys(dto).filter(
        (k) => !driverAllowedKeys.has(k) && (dto as Record<string, unknown>)[k] !== undefined,
      );
      if (tooMany.length > 0) {
        throw new BadRequestException(
          `Drivers can only update status / loadingConfirmed / instructions on their own orders. Disallowed fields: ${tooMany.join(', ')}.`,
        );
      }
      if (!existing.tripId) {
        throw new BadRequestException(
          'This order is not on any trip yet. Wait for dispatch.',
        );
      }
      // Verify the caller is the assigned driver via the trip
      const trip = await this.prisma.trip.findFirst({
        where: { id: existing.tripId, organizationId },
        select: { driverId: true },
      });
      if (!trip?.driverId) {
        throw new BadRequestException('Trip has no driver assigned.');
      }
      if (!userId) {
        throw new BadRequestException('User context missing');
      }
      const user = await this.prisma.user.findUnique({
        where: { id: userId },
        select: { email: true, firstName: true, lastName: true },
      });
      if (!user) throw new NotFoundException('User not found');
      const matching = await this.prisma.driver.findMany({
        where: {
          organizationId,
          OR: [
            ...(user.email ? [{ email: user.email }] : []),
            { firstName: user.firstName || '', lastName: user.lastName || '' },
          ],
        },
        select: { id: true },
      });
      const driverIds = new Set(matching.map((d) => d.id));
      if (!driverIds.has(trip.driverId)) {
        throw new BadRequestException('You can only update orders on your own trips.');
      }
    }

    const data: Record<string, unknown> = {};
    const fields = [
      'clientId', 'referenceNumber', 'priority', 'vehicleType',
      'originName', 'originAddress', 'originCity', 'originState', 'originZip',
      'originLat', 'originLng', 'destName', 'destAddress', 'destCity',
      'destState', 'destZip', 'destLat', 'destLng', 'commodity', 'weight',
      'volume', 'pieces', 'pallets', 'specialInstructions', 'quoteAmount',
      'jobStatus', 'lpoNumber', 'deliveryNumber', 'plantName', 'distributorName',
      'zoneId', 'laneId', 'loadingBayId', 'loadingConfirmed', 'tripId',
      'beerOrderNumber', 'udvOrderNumber', 'deliveryNumberBeer', 'deliveryNumberUdv',
      'qtyBeer', 'qtyUdv', 'truckPlate', 'palletization', 'region',
    ];

    for (const field of fields) {
      if ((dto as Record<string, unknown>)[field] !== undefined) {
        data[field] = (dto as Record<string, unknown>)[field];
      }
    }

    if (dto.pickupDate) data.pickupDate = new Date(dto.pickupDate);
    if (dto.deliveryDate) data.deliveryDate = new Date(dto.deliveryDate);

    // Handle status transition with event sourcing + state-machine guard.
    // The state machine throws BadRequestException with a clear message
    // if the transition is not allowed.
    if (dto.status && dto.status !== existing.status) {
      assertOrderStatusTransition(existing.status, dto.status);
      data.status = dto.status;
      await this.prisma.orderEvent.create({
        data: {
          orderId: id,
          fromStatus: existing.status,
          toStatus: dto.status,
          userId,
          reason: `Status changed from ${existing.status} to ${dto.status}`,
        },
      });
    }

    return this.prisma.order.update({
      where: { id },
      data,
      include: { client: true },
    });
  }

  /**
   * Delete an order. Blocked if the order is on an active trip
   * (ASSIGNED, LOADED, or IN_TRANSIT) to prevent silent data loss
   * on the dispatch board.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param id - The order's database ID.
   * @returns `{ deleted: true }` on success.
   * @throws {BadRequestException} Order is on an active trip.
   * @throws {NotFoundException} Order not found.
   */
  async remove(organizationId: string, id: string) {
    const order = await this.findOne(organizationId, id);
    // Block delete if the order is on an active trip — otherwise the
    // dispatch board would silently lose a row mid-trip.
    if (order.tripId && ['ASSIGNED', 'LOADED', 'IN_TRANSIT'].includes(order.status)) {
      throw new BadRequestException(
        `Order is on an active trip (${order.status}). Cancel or unlink before deleting.`,
      );
    }
    await this.prisma.order.delete({ where: { id } });
    return { deleted: true };
  }

  /**
   * Get order count statistics grouped by status for the dashboard KPIs.
   *
   * @param organizationId - The tenant's organisation ID.
   * @returns Object with `total` count and `byStatus` breakdown.
   */
  async getStats(organizationId: string) {
    const results = await this.prisma.order.groupBy({
      by: ['status'],
      where: { organizationId },
      _count: { status: true },
    });

    const byStatus: Record<string, number> = {};
    for (const r of results) {
      byStatus[r.status] = r._count.status;
    }

    const total = await this.prisma.order.count({ where: { organizationId } });

    return { total, byStatus };
  }

  /**
   * Confirm that an order has been physically loaded onto a vehicle.
   *
   * Enforces GPS geofence validation: the driver must be within the
   * loading bay's configured radius (default 50m) to confirm. This
   * prevents remote confirmation fraud.
   *
   * Side effects on success:
   *   - Order status set to `LOADED`, loadingConfirmed set to `true`
   *   - GPS coordinates and confirmer name appended to specialInstructions
   *   - If all orders on the trip are loaded, the bay is released to `AVAILABLE`
   *   - A `BayEvent` (LOADING_COMPLETE) is recorded for bay analytics
   *
   * @param id - The order's database ID.
   * @param userId - ID of the user confirming (for audit trail).
   * @param organizationId - The tenant's organisation ID.
   * @param latitude - Driver's current GPS latitude (required).
   * @param longitude - Driver's current GPS longitude (required).
   * @returns The updated order.
   * @throws {BadRequestException} GPS not provided, driver not within bay radius, or no bay configured.
   */
  async confirmLoading(id: string, userId: string, organizationId: string, latitude?: number, longitude?: number) {
    const order = await this.findOne(organizationId, id);
    if (order.loadingConfirmed) {
      return order; // already confirmed
    }

    // GPS coordinates are REQUIRED for loading confirmation
    if (!latitude || !longitude) {
      throw new BadRequestException('GPS location is required to confirm loading. Enable location services.');
    }

    // Validate GPS against loading bay location — truck must be AT the bay
    const tripId = order.tripId;
    let bayLat: number | null = null;
    let bayLng: number | null = null;
    let bayRadius = 50; // 50 meters — must be inside the bay, not 500m away
    let bayName = 'loading bay';

    if (tripId) {
      const trip = await this.prisma.trip.findUnique({
        where: { id: tripId },
        select: { loadingBayId: true },
      });
      if (trip?.loadingBayId) {
        const bay = await this.prisma.loadingBay.findUnique({
          where: { id: trip.loadingBayId },
        });
        if (bay) {
          bayLat = bay.latitude;
          bayLng = bay.longitude;
          bayRadius = (bay as any).radiusMeters || 50;
          bayName = bay.name;
        }
      }
    }

    // Also check ALL loading bays in the org if trip has no specific bay
    if (!bayLat && organizationId) {
      const allBays = await this.prisma.loadingBay.findMany({
        where: { organizationId, latitude: { not: null } },
        select: { latitude: true, longitude: true, name: true, radiusMeters: true },
      });
      // Find closest bay
      let closestDist = Infinity;
      for (const b of allBays) {
        if (b.latitude && b.longitude) {
          const dist = this.calculateDistance(latitude, longitude, b.latitude, b.longitude);
          if (dist < closestDist) {
            closestDist = dist;
            bayLat = b.latitude;
            bayLng = b.longitude;
            bayRadius = (b as any).radiusMeters || 50;
            bayName = b.name;
          }
        }
      }
    }

    // ENFORCE location check — driver must be at a loading bay
    if (bayLat && bayLng) {
      const distance = this.calculateDistance(latitude, longitude, bayLat, bayLng);
      if (distance > bayRadius) {
        throw new BadRequestException(
          `You are ${Math.round(distance)}m away from ${bayName}. ` +
          `You must be inside the loading bay (within ${bayRadius}m) to confirm loading. ` +
          `Drive to the bay and try again.`
        );
      }
    } else {
      // No bay with coordinates exists — reject loading confirmation
      throw new BadRequestException(
        'No loading bay with GPS coordinates is configured. ' +
        'Admin must set bay coordinates on the platform before loading can be confirmed.'
      );
    }

    // Look up who confirmed
    let confirmedByName = 'System';
    if (userId) {
      const user = await this.prisma.user.findUnique({
        where: { id: userId },
        select: { firstName: true, lastName: true },
      });
      if (user) confirmedByName = `${user.firstName || ''} ${user.lastName || ''}`.trim();
    }

    const updated = await this.prisma.order.update({
      where: { id },
      data: {
        loadingConfirmed: true,
        loadingConfirmedAt: new Date(),
        status: 'LOADED',
        jobStatus: 'LOADED',
        specialInstructions: [
          order.specialInstructions,
          `Loading confirmed by ${confirmedByName} at ${latitude?.toFixed(6) || '?'}, ${longitude?.toFixed(6) || '?'} on ${new Date().toISOString()}`,
        ].filter(Boolean).join(' | '),
      },
      include: { client: true },
    });

    // Record bay event and update bay/trip status
    if (order.tripId && organizationId) {
      const trip = await this.prisma.trip.findUnique({
        where: { id: order.tripId },
        select: { loadingBayId: true, vehicleId: true },
      });

      // Find the closest bay the driver is at
      // Priority: Trip.loadingBayId → Order.loadingBayId → closest GPS
      let matchedBayId = trip?.loadingBayId || (order as any).loadingBayId || null;
      if (!matchedBayId) {
        const allBays = await this.prisma.loadingBay.findMany({
          where: { organizationId, latitude: { not: null } },
        });
        let closestId: string | null = null;
        let closestDist = Infinity;
        for (const b of allBays) {
          if (b.latitude && b.longitude) {
            const d = this.calculateDistance(latitude, longitude, b.latitude, b.longitude);
            if (d < closestDist) { closestDist = d; closestId = b.id; }
          }
        }
        matchedBayId = closestId;

        // Update trip with the dock bay
        if (matchedBayId) {
          await this.prisma.trip.update({
            where: { id: order.tripId },
            data: { loadingBayId: matchedBayId },
          }).catch(() => {});
        }
      }

      if (matchedBayId) {
        // Check if ALL orders on this trip are now confirmed (including the one just updated)
        const remainingUnconfirmed = await this.prisma.order.count({
          where: {
            tripId: order.tripId,
            id: { not: id }, // exclude the one we just confirmed
            loadingConfirmed: false,
          },
        });
        const allLoaded = remainingUnconfirmed === 0;

        if (allLoaded) {
          // All orders loaded — release the bay so it's available for the next truck
          await this.prisma.loadingBay.update({
            where: { id: matchedBayId },
            data: { status: 'AVAILABLE', currentVehicleId: null },
          }).catch(() => {});
        } else {
          // Still loading other orders — keep bay OCCUPIED
          await this.prisma.loadingBay.update({
            where: { id: matchedBayId },
            data: { status: 'OCCUPIED', currentVehicleId: trip?.vehicleId || null },
          }).catch(() => {});
        }

        // Create bay event
        await this.prisma.bayEvent.create({
          data: {
            organizationId,
            loadingBayId: matchedBayId,
            vehicleId: trip?.vehicleId || undefined,
            tripId: order.tripId,
            eventType: 'LOADING_COMPLETE',
            actualAt: new Date(),
            status: 'ON_TIME',
            notes: `Order ${order.orderNumber} loaded by ${confirmedByName} at GPS: ${latitude?.toFixed(6)}, ${longitude?.toFixed(6)}. ${allLoaded ? 'All orders loaded — bay released.' : `${remainingUnconfirmed} order(s) still pending.`}`,
          },
        }).catch(() => {});
      }
    }

    return updated;
  }

  /**
   * Retrieve orders ready for the Jobs/Tasks planner board.
   *
   * Returns orders with `jobStatus` of `VALIDATED` or `READY`, which are
   * the states eligible for trip allocation by the planner.
   *
   * @param organizationId - The tenant's organisation ID.
   * @returns Object with `data` array and `total` count.
   */
  async getJobs(organizationId: string) {
    const orders = await this.prisma.order.findMany({
      where: {
        organizationId,
        jobStatus: { in: ['VALIDATED', 'READY'] },
      },
      include: {
        client: { select: { id: true, name: true, code: true } },
      },
      orderBy: { createdAt: 'desc' },
    });

    return { data: orders, total: orders.length };
  }

  /**
   * Advance an order's job status through the planner workflow.
   *
   * Valid transitions: `PENDING` -> `VALIDATED` -> `READY` -> `ALLOCATED`.
   *
   * @param organizationId - The tenant's organisation ID.
   * @param id - The order's database ID.
   * @param newJobStatus - The target job status.
   * @param userId - ID of the user (for audit trail).
   * @returns The updated order with client relation.
   * @throws {BadRequestException} Invalid job status transition.
   */
  async updateJobStatus(organizationId: string, id: string, newJobStatus: string, userId?: string) {
    const validTransitions: Record<string, string[]> = {
      PENDING: ['VALIDATED'],
      VALIDATED: ['READY'],
      READY: ['ALLOCATED'],
    };

    const order = await this.findOne(organizationId, id);
    const currentJobStatus = order.jobStatus || 'PENDING';
    const allowed = validTransitions[currentJobStatus] || [];

    if (!allowed.includes(newJobStatus)) {
      throw new BadRequestException(
        `Invalid job status transition: ${currentJobStatus} -> ${newJobStatus}. Allowed: ${allowed.join(', ')}`,
      );
    }

    return this.prisma.order.update({
      where: { id },
      data: { jobStatus: newJobStatus },
      include: { client: true },
    });
  }

  /**
   * Manually assign a vehicle to an order. Creates a trip if needed.
   */
  async assignVehicle(orderId: string, vehicleId: string, organizationId: string, userId: string) {
    const order = await this.findOne(organizationId, orderId);

    if (order.tripId) {
      throw new BadRequestException('Order already assigned to a trip. Remove it first.');
    }

    // Vehicle must belong to the same org — defence in depth even with RLS
    const vehicle = await this.prisma.vehicle.findFirst({
      where: { id: vehicleId, organizationId },
    });
    if (!vehicle) throw new NotFoundException('Vehicle not found');

    // Capacity sanity check — refuse if order weight exceeds vehicle max
    if (order.weight && vehicle.maxWeight && order.weight > vehicle.maxWeight) {
      throw new BadRequestException(
        `Order weight (${order.weight}kg) exceeds vehicle max payload (${vehicle.maxWeight}kg).`,
      );
    }

    // Create a trip for this order
    const tripCount = await this.prisma.trip.count({ where: { organizationId } });
    const tripNumber = `TRP-${String(tripCount + 1).padStart(6, '0')}`;

    const trip = await this.prisma.trip.create({
      data: {
        organizationId,
        tripNumber,
        vehicleId,
        status: 'PLANNED',
      },
    });

    const updated = await this.prisma.order.update({
      where: { id: orderId },
      data: {
        tripId: trip.id,
        status: 'ASSIGNED',
        jobStatus: 'ALLOCATED',
        truckPlate: vehicle.licensePlate,
      },
      include: { client: true, trip: { include: { vehicle: true } } },
    });

    await this.prisma.orderEvent.create({
      data: {
        orderId,
        fromStatus: order.status,
        toStatus: 'ASSIGNED',
        userId,
        reason: `Manually assigned to vehicle ${vehicle.unitNumber} (${vehicle.licensePlate})`,
      },
    });

    return updated;
  }

  /**
   * Waive a missing field — mark as intentionally skipped with who/when/why.
   */
  async waiveField(organizationId: string, orderId: string, field: string, userId: string, userName: string, reason?: string) {
    const order = await this.findOne(organizationId, orderId);

    const currentMissing = order.missingFields || [];
    if (!currentMissing.includes(field)) {
      throw new BadRequestException(`"${field}" is not in the missing fields list`);
    }

    // Remove from missingFields
    const updatedMissing = currentMissing.filter((f: string) => f !== field);

    // Add to waivedFields
    const currentWaivers = (order.waivedFields as any[]) || [];
    currentWaivers.push({
      field,
      waivedBy: userId,
      waivedByName: userName,
      waivedAt: new Date().toISOString(),
      reason: reason || 'Waived by user',
    });

    const updated = await this.prisma.order.update({
      where: { id: orderId },
      data: {
        missingFields: updatedMissing,
        waivedFields: currentWaivers as any,
      },
    });

    await this.prisma.orderEvent.create({
      data: {
        orderId,
        toStatus: 'DRAFT',
        userId,
        reason: `Waived missing field "${field}"${reason ? `: ${reason}` : ''}`,
      },
    });

    return updated;
  }

  /**
   * Send order to Jobs — only if all missing fields are filled or waived.
   */
  async sendToJobs(organizationId: string, orderId: string, userId: string) {
    const order = await this.findOne(organizationId, orderId);

    const remaining = order.missingFields || [];
    if (remaining.length > 0) {
      throw new BadRequestException(
        `Cannot send to Jobs — ${remaining.length} missing fields need to be filled or waived: ${remaining.join(', ')}`,
      );
    }

    const updated = await this.prisma.order.update({
      where: { id: orderId },
      data: { jobStatus: 'PENDING' },
      include: { client: true },
    });

    await this.prisma.orderEvent.create({
      data: {
        orderId,
        toStatus: 'DRAFT',
        userId,
        reason: 'Sent to Jobs board — all missing fields resolved',
      },
    });

    return updated;
  }

  /**
   * Bulk send all eligible orders to Jobs.
   * Only orders with 0 missing fields get sent. The rest are rejected with reasons.
   */
  async bulkSendToJobs(organizationId: string, userId: string) {
    const importedOrders = await this.prisma.order.findMany({
      where: { organizationId, jobStatus: 'IMPORTED' },
      select: { id: true, orderNumber: true, missingFields: true },
    });

    let sent = 0;
    let rejected = 0;
    const rejectedOrders: { orderNumber: string; missing: string[] }[] = [];

    for (const order of importedOrders) {
      if (!order.missingFields || order.missingFields.length === 0) {
        await this.prisma.order.update({
          where: { id: order.id },
          data: { jobStatus: 'PENDING' },
        });
        sent++;
      } else {
        rejected++;
        rejectedOrders.push({ orderNumber: order.orderNumber, missing: order.missingFields });
      }
    }

    return {
      total: importedOrders.length,
      sent,
      rejected,
      rejectedOrders: rejectedOrders.slice(0, 20),
      message: rejected > 0
        ? `${sent} orders sent to Jobs. ${rejected} rejected — fill or waive missing data first.`
        : `All ${sent} orders sent to Jobs.`,
    };
  }

  /**
   * Calculate distance between two GPS coordinates in meters (Haversine formula).
   */
  private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
    const R = 6371000; // Earth radius in meters
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLng = (lng2 - lng1) * Math.PI / 180;
    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
      Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
      Math.sin(dLng / 2) * Math.sin(dLng / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return R * c;
  }

  /**
   * Generate the next sequential order number for an organisation.
   *
   * Format: `ORD-NNNNNN` (zero-padded to 6 digits). The number is derived
   * from the current order count, so it is unique within the tenant but
   * not globally unique across tenants.
   *
   * @param organizationId - The tenant's organisation ID.
   * @returns The generated order number string (e.g., `ORD-000042`).
   */
  private async generateOrderNumber(organizationId: string): Promise<string> {
    const count = await this.prisma.order.count({ where: { organizationId } });
    const num = count + 1;
    return `ORD-${String(num).padStart(6, '0')}`;
  }
}

results matching ""

    No results matching ""