Fix inventory status column

This commit is contained in:
hagiye
2026-06-25 13:07:50 +03:00
6 changed files with 271 additions and 77 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}
}
}

View File

@@ -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}

View File

@@ -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: .

View File

@@ -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