mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Fix inventory status column
This commit is contained in:
@@ -133,7 +133,7 @@ export class FirstMileService {
|
||||
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
|
||||
return this.firstMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'PAYMENT_PENDING',
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
remainingPayment: dto.remainingPayment ?? 0,
|
||||
estimatedKm: dto.estimatedKm ?? null,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<<<<<<< HEAD
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, FindOptionsWhere } from 'typeorm';
|
||||
=======
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
>>>>>>> 73de29f7c5977827a68865b9cb492a5a2cf1cead
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
@@ -40,52 +45,15 @@ export class LastMileService {
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile> {
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
|
||||
);
|
||||
}
|
||||
|
||||
if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
|
||||
throw new BadRequestException(`Booking ${bookingReference} is not an import booking`);
|
||||
}
|
||||
|
||||
if (!booking.lastMileDeliveryAddress?.trim()) {
|
||||
throw new BadRequestException(`Booking ${bookingReference} has no last-mile delivery address`);
|
||||
}
|
||||
|
||||
const [eligibleInventory] = await this.dataSource.query(
|
||||
`SELECT inv.id
|
||||
FROM freight.warehouse_inventory inv
|
||||
WHERE inv.booking_id = $1
|
||||
AND inv.deleted_at IS NULL
|
||||
AND inv.status = 'READY_FOR_PICKUP'
|
||||
AND inv.inspection_status = 'PASSED'
|
||||
LIMIT 1`,
|
||||
[booking.id],
|
||||
);
|
||||
if (!eligibleInventory) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${bookingReference} is not eligible for last mile. Import inventory must pass inspection and be READY_FOR_PICKUP.`,
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await this.dataSource.query(
|
||||
`SELECT id
|
||||
FROM freight.last_mile
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[booking.id],
|
||||
);
|
||||
if (existing) {
|
||||
return this.findById(existing.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
@@ -94,17 +62,15 @@ export class LastMileService {
|
||||
});
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<LastMile> {
|
||||
async acceptBookingByReference(bookingReference: string): Promise<LastMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
@@ -169,7 +135,7 @@ export class LastMileService {
|
||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||
return this.lastMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'PAYMENT_PENDING',
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
remainingPayment: dto.remainingPayment ?? 0,
|
||||
estimatedKm: dto.estimatedKm ?? null,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import axios from "axios";
|
||||
import axios, { isAxiosError } from "axios";
|
||||
|
||||
import { NotificationStrategy } from "./notification.strategy";
|
||||
|
||||
@Injectable()
|
||||
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||
private readonly logger = new Logger(SmsNotificationStrategy.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async send(recipient: string, message: string): Promise<boolean> {
|
||||
@@ -13,24 +15,43 @@ export class SmsNotificationStrategy implements NotificationStrategy {
|
||||
this.configService.get<string>("OZIKING_SMS_URL") ??
|
||||
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
|
||||
|
||||
await axios.post(
|
||||
url,
|
||||
{
|
||||
to: recipient,
|
||||
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
|
||||
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
|
||||
appKey: this.configService.get<string>("OZIKING_APP_KEY") ?? "",
|
||||
text: message,
|
||||
callbackUrl: "",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const appKey = this.configService.get<string>("OZIKING_APP_KEY") ?? "";
|
||||
if (!appKey) {
|
||||
this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API");
|
||||
}
|
||||
|
||||
return true;
|
||||
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
to: recipient,
|
||||
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
|
||||
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
|
||||
appKey,
|
||||
text: message,
|
||||
callbackUrl: "",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isAxiosError(err)) {
|
||||
this.logger.error(
|
||||
`SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`SMS send failed: ${String(err)}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,15 @@ import {
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import type { ArrivalQueueItem } from "@/types/warehouse";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
@@ -297,6 +300,13 @@ const LastMilePage = () => {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
|
||||
|
||||
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
||||
const [selectedArrivalItems, setSelectedArrivalItems] = useState<ArrivalQueueItem[]>([]);
|
||||
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
||||
const [arrivalSearch, setArrivalSearch] = useState("");
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||
queryFn: async () => {
|
||||
@@ -338,6 +348,76 @@ const LastMilePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
|
||||
queryKey: ["warehouse-inventory", "arrival-queue"],
|
||||
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||
enabled: acceptOpen,
|
||||
});
|
||||
const arrivalQueue = arrivalQueueData ?? [];
|
||||
|
||||
const filteredArrivalQueue = useMemo(() => {
|
||||
const term = arrivalSearch.trim().toLowerCase();
|
||||
if (!term) return arrivalQueue;
|
||||
return arrivalQueue.filter((item) =>
|
||||
[item.bookingReference, item.customer, item.cargo, item.warehouse, item.yard]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(term),
|
||||
);
|
||||
}, [arrivalQueue, arrivalSearch]);
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => {
|
||||
const created = await Promise.all(
|
||||
items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)),
|
||||
);
|
||||
if (vehicleId) {
|
||||
await Promise.all(created.map((record) => lastMileService.update(record.id, { vehicleId })));
|
||||
}
|
||||
return created;
|
||||
},
|
||||
onSuccess: (created) => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
toast({
|
||||
title: "Last-mile leg created",
|
||||
description: `${created.length} ${created.length === 1 ? "delivery" : "deliveries"} accepted successfully.`,
|
||||
});
|
||||
closeAccept();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Accept failed", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const openAccept = () => {
|
||||
setAcceptOpen(true);
|
||||
setAcceptStep(1);
|
||||
setSelectedArrivalItems([]);
|
||||
setAcceptVehicleValue(null);
|
||||
setArrivalSearch("");
|
||||
};
|
||||
|
||||
const closeAccept = () => {
|
||||
setAcceptOpen(false);
|
||||
setAcceptStep(1);
|
||||
setSelectedArrivalItems([]);
|
||||
setAcceptVehicleValue(null);
|
||||
setArrivalSearch("");
|
||||
};
|
||||
|
||||
const toggleArrivalItem = (item: ArrivalQueueItem) => {
|
||||
setSelectedArrivalItems((prev) =>
|
||||
prev.some((i) => i.bookingId === item.bookingId)
|
||||
? prev.filter((i) => i.bookingId !== item.bookingId)
|
||||
: [...prev, item],
|
||||
);
|
||||
};
|
||||
|
||||
const handleAcceptConfirm = () => {
|
||||
if (!selectedArrivalItems.length) return;
|
||||
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
|
||||
};
|
||||
|
||||
const activeRecord = useMemo(
|
||||
() => records.find((r) => r.id === activeId) ?? null,
|
||||
[records, activeId],
|
||||
@@ -633,7 +713,7 @@ const LastMilePage = () => {
|
||||
Assign vehicle ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)} styles={{ label: { fontWeight: 500 } }}>
|
||||
<Button leftSection={<Truck size={16} />} onClick={openAccept} styles={{ label: { fontWeight: 500 } }}>
|
||||
Assign Mile
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -688,6 +768,132 @@ const LastMilePage = () => {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 2-step Assign Mile (arrival queue → vehicle) */}
|
||||
<Modal
|
||||
opened={acceptOpen}
|
||||
onClose={closeAccept}
|
||||
title={
|
||||
<Text fw={600}>
|
||||
{acceptStep === 1 ? "Select Arrivals" : "Assign Mile"}
|
||||
</Text>
|
||||
}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
{acceptStep === 1 ? (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
placeholder="Search by reference, customer, cargo or warehouse…"
|
||||
value={arrivalSearch}
|
||||
onChange={(e) => setArrivalSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<ScrollArea h={400}>
|
||||
<Stack gap="xs">
|
||||
{arrivalLoading ? (
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">Loading arrivals…</Text>
|
||||
) : filteredArrivalQueue.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">No arrivals in queue.</Text>
|
||||
) : (
|
||||
filteredArrivalQueue.map((item) => {
|
||||
const checked = selectedArrivalItems.some((i) => i.bookingId === item.bookingId);
|
||||
return (
|
||||
<Card
|
||||
key={item.bookingId}
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: checked ? "var(--mantine-color-blue-4)" : "var(--mantine-color-gray-3)",
|
||||
backgroundColor: checked ? "var(--mantine-color-blue-0)" : "var(--mantine-color-white)",
|
||||
transition: "background-color 120ms ease, border-color 120ms ease",
|
||||
}}
|
||||
onClick={() => toggleArrivalItem(item)}
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={() => toggleArrivalItem(item)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={700} size="sm">{item.bookingReference}</Text>
|
||||
<Text size="xs" c="dimmed">{item.arrivalDate ? item.arrivalDate.slice(0, 10) : "—"}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>{item.customer ?? "—"}</Text>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{item.cargo && <Text size="xs" c="dimmed">{item.cargo}</Text>}
|
||||
{item.warehouse && <Text size="xs" c="dimmed">· {item.warehouse}</Text>}
|
||||
{item.yard && <Text size="xs" c="dimmed">· {item.yard}</Text>}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{selectedArrivalItems.length > 0
|
||||
? `${selectedArrivalItems.length} selected`
|
||||
: "Select one or more arrivals"}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={closeAccept}>Cancel</Button>
|
||||
<Button
|
||||
disabled={selectedArrivalItems.length === 0}
|
||||
onClick={() => setAcceptStep(2)}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Card withBorder padding="sm" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600} c="dimmed">Selected arrivals ({selectedArrivalItems.length})</Text>
|
||||
{selectedArrivalItems.map((item) => (
|
||||
<Group key={item.bookingId} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>{item.bookingReference}</Text>
|
||||
<Text size="xs" c="dimmed">{item.customer ?? "—"}</Text>
|
||||
<Text size="xs" c="dimmed">{item.warehouse ?? item.yard ?? "—"}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Divider />
|
||||
<Select
|
||||
label="Assign Vehicle (optional)"
|
||||
placeholder="Select a vehicle"
|
||||
data={vehicleOptions}
|
||||
value={acceptVehicleValue}
|
||||
onChange={setAcceptVehicleValue}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Button variant="subtle" onClick={() => setAcceptStep(1)}>← Back</Button>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={closeAccept}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleAcceptConfirm}
|
||||
loading={acceptMutation.isPending}
|
||||
disabled={selectedArrivalItems.length === 0}
|
||||
>
|
||||
Accept {selectedArrivalItems.length > 1 ? `${selectedArrivalItems.length} Deliveries` : "Delivery"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Assign / Reassign modal */}
|
||||
<Modal
|
||||
opened={assignOpen}
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
#
|
||||
# Build: DOCKER_BUILDKIT=1 docker compose build
|
||||
# Run: docker compose up -d
|
||||
|
||||
services:
|
||||
# Message broker for payment event delivery (payment-api -> passenger/freight).
|
||||
# Management UI: http://localhost:15672 (login edr / edr_secret). vhost: payment.
|
||||
# In deployed envs this is a shared/managed RabbitMQ; only PAYMENT_RABBITMQ_URL changes.
|
||||
|
||||
freight-api:
|
||||
build:
|
||||
context: .
|
||||
@@ -41,13 +39,14 @@ services:
|
||||
args:
|
||||
TURBO_FILTER: "@edr/freight-portal"
|
||||
APP_PATH: apps/edr-freight-web/portal
|
||||
# Browser-reachable URL; override for production deployments
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com}
|
||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_PORTAL_PORT:-5173}:80"
|
||||
|
||||
|
||||
freight-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -55,12 +54,14 @@ services:
|
||||
args:
|
||||
TURBO_FILTER: "@edr/freight-backoffice"
|
||||
APP_PATH: apps/edr-freight-web/backoffice
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com}
|
||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
|
||||
|
||||
|
||||
passenger-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -75,7 +76,7 @@ services:
|
||||
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
|
||||
env_file:
|
||||
- apps/edr-passenger-web/portal/.env
|
||||
|
||||
|
||||
passenger-backoffice:
|
||||
build:
|
||||
context: .
|
||||
|
||||
@@ -4,7 +4,7 @@ ARG TURBO_FILTER=@edr/freight-portal
|
||||
ARG APP_PATH=apps/edr-freight-web/portal
|
||||
ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api
|
||||
ARG VITE_BASE_API_URL=https://edrfreightapi.triaplc.com
|
||||
ARG VITE_API_URL=VITE_USER_MANAGEMENT_BASE=/_um
|
||||
ARG VITE_USER_MANAGEMENT_BASE=/_um
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
|
||||
Reference in New Issue
Block a user