Saltar al contenido principal

Goods Received Note (GRN) Implementation Plan

Overview

This document outlines the implementation plan for adding Goods Received Note (GRN) functionality to the existing purchase orders system. The GRN will track what was received against what was ordered, support partial receipts, update inventory accurately, handle discrepancies, and provide audit trails.

Current System Analysis

Existing Purchase Orders Structure

  • Backend: Clean architecture with domain, application, infrastructure, and interfaces layers
  • Database: Uses Kysely with PostgreSQL, JSONB for complex data structures
  • Frontend: React PWA with TypeScript, form-based interface
  • Key Features: PDF generation, document numbering, inventory integration

Current Purchase Order Schema

interface PurchaseOrder {
id: string;
businessId: string;
supplierId: string;
locationId: string;
orderDate: Timestamp;
deliveryDate: Timestamp;
status: string;
totalAmount: Numeric;
purchaseDetail: Json; // Contains items array
paymentDetail: Json;
// ... other fields
}

Implementation Plan

Phase 1: Database Schema & Types

1.1 Database Migration

Create new table goods_received_notes:

CREATE TABLE goods_received_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_id UUID NOT NULL REFERENCES businesses(id),
purchase_order_id UUID NOT NULL REFERENCES purchase_orders(id),
location_id UUID NOT NULL REFERENCES locations(id),
received_by_user_id UUID NOT NULL REFERENCES users(id),
received_date TIMESTAMP NOT NULL,
goods_received_detail JSONB NOT NULL,
note TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID REFERENCES users(id)
);

-- Indexes for performance
CREATE INDEX idx_grn_business_id ON goods_received_notes(business_id);
CREATE INDEX idx_grn_purchase_order_id ON goods_received_notes(purchase_order_id);
CREATE INDEX idx_grn_status ON goods_received_notes(status);
CREATE INDEX idx_grn_received_date ON goods_received_notes(received_date);

1.2 Type Definitions

File: packages/backend/database/src/types/goods-received-note.types.ts

import type { Insertable, Selectable, Updateable } from "kysely";
import type { GoodsReceivedNote } from "./database.types";

export type SelectableGoodsReceivedNote = Selectable<GoodsReceivedNote>;
export type InsertableGoodsReceivedNote = Insertable<GoodsReceivedNote>;
export type UpdateableGoodsReceivedNote = Updateable<GoodsReceivedNote>;

File: packages/backend/database/src/types/database.types.ts (add to DB interface)

export interface GoodsReceivedNote {
id: Generated<string>;
businessId: string;
purchaseOrderId: string;
locationId: string;
receivedByUserId: string;
receivedDate: Timestamp;
goodsReceivedDetail: Json;
note: string | null;
status: Generated<string>;
createdAt: Generated<Timestamp>;
updatedAt: Timestamp | null;
createdBy: string;
updatedBy: string | null;
}

// Add to DB interface
export interface DB {
// ... existing tables
goodsReceivedNote: GoodsReceivedNote;
}

1.3 Enums

File: packages/global/enums/goods-received-note.enums.ts

export enum GoodsReceivedNoteStatus {
DRAFT = "draft",
SUBMITTED = "submitted",
REVIEWED = "reviewed",
}

Phase 2: Backend Implementation

2.1 Module Structure

Create new module: apps/backend/src/goods-received-notes/

goods-received-notes/
├── goods-received-notes.module.ts
├── domain/
│ └── goods-received-notes-repository.domain.ts
├── application/
│ ├── goods-received-notes.service.ts
│ └── events/
│ └── on-submit-goods-received-note.event.ts
├── infrastructure/
│ └── goods-received-notes.repository.ts
└── interfaces/
├── goods-received-notes.controller.ts
├── dtos/
│ ├── create-goods-received-note.dto.ts
│ └── update-goods-received-note.dto.ts
└── query/
└── paginate-goods-received-notes.query.ts

2.2 Domain Layer

File: apps/backend/src/goods-received-notes/domain/goods-received-notes-repository.domain.ts

import type { sortableGoodsReceivedNoteKeys } from "@/goods-received-notes/interfaces/query/paginate-goods-received-notes.query";
import type {
IRepositoryActionByIdWithBusiness,
IRepositoryOffsetFindAll,
IRepositoryUpdate,
} from "@/utils/repository.utils";
import type { SimplifySingleResult } from "kysely/dist/cjs/util/type-utils";
import type {
InsertableGoodsReceivedNote,
SelectableGoodsReceivedNote,
UpdateableGoodsReceivedNote,
} from "@flowpos-workspace/backend-database/types/goods-received-note.types";

export interface IGoodsReceivedNotesRepository {
create(
grn: InsertableGoodsReceivedNote
): Promise<SelectableGoodsReceivedNote>;
findAll(
parameters: IRepositoryOffsetFindAll<
(typeof sortableGoodsReceivedNoteKeys)[number]
>
): Promise<[number, SelectableGoodsReceivedNote[]]>;
findById(
id: string
): Promise<SimplifySingleResult<SelectableGoodsReceivedNote>>;
findByPurchaseOrderId(
purchaseOrderId: string
): Promise<SelectableGoodsReceivedNote[]>;
update(
parameters: IRepositoryUpdate<UpdateableGoodsReceivedNote>
): Promise<SelectableGoodsReceivedNote>;
delete(parameters: IRepositoryActionByIdWithBusiness): Promise<void>;
}

2.3 Application Layer

File: apps/backend/src/goods-received-notes/application/goods-received-notes.service.ts

import { Injectable, Inject } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { GoodsReceivedNotesRepository } from "@/goods-received-notes/infrastructure/goods-received-notes.repository";
import { InventoriesRepository } from "@/inventories/infrastructure/inventories.repository";
import { InventoryDetailsService } from "@/inventory-details/application/inventory-details.service";
import { generateDocumentNumber } from "@/common/services/document-number.service";
import {
DATABASE,
type KyselyDatabase,
} from "@/database/infrastructure/database.providers";
import { GoodsReceivedNoteStatus } from "@flowpos-workspace/global/enums/goods-received-note.enums";
import type {
InsertableGoodsReceivedNote,
SelectableGoodsReceivedNote,
UpdateableGoodsReceivedNote,
} from "@flowpos-workspace/backend-database/types/goods-received-note.types";

@Injectable()
export class GoodsReceivedNotesService {
constructor(
private readonly goodsReceivedNotesRepository: GoodsReceivedNotesRepository,
private readonly inventoriesRepository: InventoriesRepository,
private readonly inventoryDetailsService: InventoryDetailsService,
private readonly eventEmitter: EventEmitter2,
@Inject(DATABASE) private readonly database: KyselyDatabase
) {}

async createGoodsReceivedNote(
grn: InsertableGoodsReceivedNote
): Promise<SelectableGoodsReceivedNote> {
return await this.database.transaction().execute(async (trx) => {
// Generate document number
const documentNumber = await generateDocumentNumber({
trx,
businessId: grn.businessId,
documentType: "goodsReceivedNote",
});

// Create GRN
const newGrn = await this.goodsReceivedNotesRepository.create(
{
...grn,
documentNumber,
},
trx
);

return newGrn;
});
}

async submitGoodsReceivedNote(
id: string
): Promise<SelectableGoodsReceivedNote> {
return await this.database.transaction().execute(async (trx) => {
const grn = await this.goodsReceivedNotesRepository.findById(id);
if (!grn) {
throw new Error(`GRN with id ${id} not found`);
}

if (grn.status !== GoodsReceivedNoteStatus.DRAFT) {
throw new Error(`GRN is not in draft status`);
}

// Update inventory based on received items
await this.updateInventoryFromGrn(grn);

// Update GRN status
const updatedGrn = await this.goodsReceivedNotesRepository.update(
{
id,
status: GoodsReceivedNoteStatus.SUBMITTED,
updatedAt: new Date(),
},
trx
);

// Emit event
this.eventEmitter.emit("goodsReceivedNote.submitted", {
grn: updatedGrn,
});

return updatedGrn;
});
}

private async updateInventoryFromGrn(
grn: SelectableGoodsReceivedNote
): Promise<void> {
const receivedItems = grn.goodsReceivedDetail.items || [];

for (const item of receivedItems) {
const netQuantity = item.receivedQuantity - (item.damagedQuantity || 0);

if (netQuantity > 0) {
await this.inventoryDetailsService.createInventoryDetailsFromGrn({
productId: item.productId,
quantity: netQuantity,
locationId: grn.locationId,
businessId: grn.businessId,
receivedDate: grn.receivedDate,
batchNumber: item.batchNumber,
serialNumber: item.serialNumber,
});
}
}
}

// ... other CRUD methods
}

2.4 Infrastructure Layer

File: apps/backend/src/goods-received-notes/infrastructure/goods-received-notes.repository.ts

import { Injectable, Inject } from "@nestjs/common";
import {
DATABASE,
type KyselyDatabase,
} from "@/database/infrastructure/database.providers";
import { IGoodsReceivedNotesRepository } from "@/goods-received-notes/domain/goods-received-notes-repository.domain";
import type {
InsertableGoodsReceivedNote,
SelectableGoodsReceivedNote,
UpdateableGoodsReceivedNote,
} from "@flowpos-workspace/backend-database/types/goods-received-note.types";

@Injectable()
export class GoodsReceivedNotesRepository
implements IGoodsReceivedNotesRepository
{
constructor(@Inject(DATABASE) private readonly database: KyselyDatabase) {}

async create(
grn: InsertableGoodsReceivedNote
): Promise<SelectableGoodsReceivedNote> {
const [newGrn] = await this.database
.insertInto("goodsReceivedNote")
.values(grn)
.returningAll();

return newGrn;
}

async findByPurchaseOrderId(
purchaseOrderId: string
): Promise<SelectableGoodsReceivedNote[]> {
return await this.database
.selectFrom("goodsReceivedNote")
.where("purchaseOrderId", "=", purchaseOrderId)
.selectAll()
.execute();
}

// ... other repository methods
}

2.5 Interfaces Layer

File: apps/backend/src/goods-received-notes/interfaces/dtos/create-goods-received-note.dto.ts

import {
IsNotEmpty,
IsString,
IsUUID,
IsObject,
IsOptional,
IsArray,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";

export class GoodsReceivedItemDto {
@IsNotEmpty()
@IsUUID()
productId: string;

@IsNotEmpty()
@IsNumber()
orderedQuantity: number;

@IsNotEmpty()
@IsNumber()
receivedQuantity: number;

@IsOptional()
@IsNumber()
damagedQuantity?: number;

@IsNotEmpty()
@IsNumber()
unitPrice: number;

@IsOptional()
@IsString()
note?: string;

@IsOptional()
@IsString()
batchNumber?: string;

@IsOptional()
@IsString()
serialNumber?: string;
}

export class CreateGoodsReceivedNoteDTO {
@IsNotEmpty()
@IsUUID()
businessId: string;

@IsNotEmpty()
@IsUUID()
purchaseOrderId: string;

@IsNotEmpty()
@IsUUID()
locationId: string;

@IsNotEmpty()
@IsUUID()
receivedByUserId: string;

@IsNotEmpty()
@IsString()
receivedDate: string;

@IsNotEmpty()
@IsArray()
@ValidateNested({ each: true })
@Type(() => GoodsReceivedItemDto)
goodsReceivedDetail: GoodsReceivedItemDto[];

@IsOptional()
@IsString()
note?: string;

@IsNotEmpty()
@IsUUID()
createdBy: string;
}

File: apps/backend/src/goods-received-notes/interfaces/goods-received-notes.controller.ts

import {
Controller,
Post,
Get,
Put,
Delete,
Body,
Param,
Query,
} from "@nestjs/common";
import { GoodsReceivedNotesService } from "@/goods-received-notes/application/goods-received-notes.service";
import { CreateGoodsReceivedNoteDTO } from "@/goods-received-notes/interfaces/dtos/create-goods-received-note.dto";
import type { SelectableGoodsReceivedNote } from "@flowpos-workspace/backend-database/types/goods-received-note.types";

@Controller("goods-received-notes")
export class GoodsReceivedNotesController {
constructor(
private readonly goodsReceivedNotesService: GoodsReceivedNotesService
) {}

@Post()
async createGoodsReceivedNote(
@Body() createGrnDTO: CreateGoodsReceivedNoteDTO
): Promise<SelectableGoodsReceivedNote> {
return this.goodsReceivedNotesService.createGoodsReceivedNote(createGrnDTO);
}

@Post(":id/submit")
async submitGoodsReceivedNote(
@Param("id") id: string
): Promise<SelectableGoodsReceivedNote> {
return this.goodsReceivedNotesService.submitGoodsReceivedNote(id);
}

@Get("purchase-order/:purchaseOrderId")
async getGoodsReceivedNotesByPurchaseOrder(
@Param("purchaseOrderId") purchaseOrderId: string
): Promise<SelectableGoodsReceivedNote[]> {
return this.goodsReceivedNotesService.findByPurchaseOrderId(
purchaseOrderId
);
}

// ... other endpoints
}

2.6 Module Registration

File: apps/backend/src/goods-received-notes/goods-received-notes.module.ts

import { Module } from "@nestjs/common";
import { DatabaseModule } from "@/database/database.module";
import { InventoriesRepository } from "@/inventories/infrastructure/inventories.repository";
import { InventoryDetailsModule } from "@/inventory-details/inventory-details.module";
import { GoodsReceivedNotesService } from "@/goods-received-notes/application/goods-received-notes.service";
import { GoodsReceivedNotesRepository } from "@/goods-received-notes/infrastructure/goods-received-notes.repository";
import { GoodsReceivedNotesController } from "@/goods-received-notes/interfaces/goods-received-notes.controller";

@Module({
imports: [DatabaseModule, InventoryDetailsModule],
controllers: [GoodsReceivedNotesController],
providers: [
GoodsReceivedNotesService,
GoodsReceivedNotesRepository,
InventoriesRepository,
],
exports: [GoodsReceivedNotesService],
})
export class GoodsReceivedNotesModule {}

Phase 3: Frontend Implementation

3.1 Types

File: apps/frontend-pwa/src/types/goodsReceivedNote.ts

export interface GoodsReceivedItem {
productId: string;
product?: Product;
orderedQuantity: number;
receivedQuantity: number;
damagedQuantity?: number;
unitPrice: number;
note?: string;
batchNumber?: string;
serialNumber?: string;
}

export interface GoodsReceivedNote {
id?: string;
businessId: string;
purchaseOrderId: string;
locationId: string;
locationName?: string;
receivedByUserId: string;
receivedByUser?: User;
receivedDate: string;
goodsReceivedDetail: GoodsReceivedItem[];
note?: string;
status: GoodsReceivedNoteStatus;
documentNumber?: string;
createdAt?: string;
createdBy: string;
updatedAt?: string;
updatedBy?: string;
}

export enum GoodsReceivedNoteStatus {
DRAFT = "draft",
SUBMITTED = "submitted",
REVIEWED = "reviewed",
}

export interface GoodsReceivedNoteFormData {
id?: string;
purchaseOrderId: string;
locationId: string;
receivedDate: Date;
items: GoodsReceivedItem[];
note?: string;
}

3.2 Services

File: apps/frontend-pwa/src/services/goodsReceivedNoteService.ts

import { api, apiText } from "@/lib/api";
import type {
GoodsReceivedNote,
GoodsReceivedNoteFormData,
} from "@/types/goodsReceivedNote";
import type { IOffsetPagination } from "@flowpos-workspace/global/interfaces/pagination.interfaces";

const baseUrl = "/goods-received-notes";

export async function getGoodsReceivedNotes(
token: string,
params?: {
page?: number;
size?: number;
status?: string;
purchaseOrderId?: string;
}
): Promise<IOffsetPagination<GoodsReceivedNote>> {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.append("page", params.page.toString());
if (params?.size) searchParams.append("size", params.size.toString());
if (params?.status) searchParams.append("status", params.status);
if (params?.purchaseOrderId)
searchParams.append("purchaseOrderId", params.purchaseOrderId);

const response = await api<IOffsetPagination<GoodsReceivedNote>>(
`${baseUrl}?${searchParams.toString()}`,
token
);
return response;
}

export async function getGoodsReceivedNoteById(
token: string,
id: string
): Promise<GoodsReceivedNote> {
return api<GoodsReceivedNote>(`${baseUrl}/${id}`, token);
}

export async function createGoodsReceivedNote(
token: string,
data: Partial<GoodsReceivedNote>
): Promise<GoodsReceivedNote> {
return api<GoodsReceivedNote>(baseUrl, token, {
method: "POST",
body: JSON.stringify(data),
});
}

export async function submitGoodsReceivedNote(
token: string,
id: string
): Promise<GoodsReceivedNote> {
return api<GoodsReceivedNote>(`${baseUrl}/${id}/submit`, token, {
method: "POST",
});
}

export async function getGoodsReceivedNotesByPurchaseOrder(
token: string,
purchaseOrderId: string
): Promise<GoodsReceivedNote[]> {
return api<GoodsReceivedNote[]>(
`${baseUrl}/purchase-order/${purchaseOrderId}`,
token
);
}

3.3 Components

3.3.1 Main Page Component

File: apps/frontend-pwa/src/components/forms/GoodsReceivedNotePage.tsx

Purpose: This is the main page component that handles the Goods Received Note creation workflow. It serves as the entry point for users to create and manage GRNs for a specific purchase order.

Key Responsibilities:

  • Manage form state and submission
  • Handle authentication and user context
  • Coordinate with business and location context
  • Provide error handling and loading states
  • Integrate with the GRN form component

Implementation Details:

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/contexts/AuthContext";
import { useCurrentBusiness } from "@/contexts/useCurrentBusiness";
import { useCurrentLocation } from "@/contexts/useCurrentLocation";
import { useToast } from "@/hooks/use-toast";
import { useFetchUser } from "@/hooks/useFetchUser";
import {
createGoodsReceivedNote,
submitGoodsReceivedNote,
} from "@/services/goodsReceivedNoteService";
import {
GoodsReceivedNote,
GoodsReceivedNoteFormData,
} from "@/types/goodsReceivedNote";
import { GoodsReceivedNoteForm } from "./goods-received-note/GoodsReceivedNoteForm";
import LoadingSpinner from "@/components/ui/LoadingSpinner";
import ErrorDisplay from "@/components/ui/ErrorDisplay";

export function GoodsReceivedNotePage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdGrn, setCreatedGrn] = useState<GoodsReceivedNote | null>(null);

const { token } = useAuth();
const { currentBusiness } = useCurrentBusiness();
const { currentLocation } = useCurrentLocation();
const { t } = useTranslation();
const { user, isUserLoading, userError } = useFetchUser(token);
const { toast } = useToast();

const handleSubmit = async (formData: GoodsReceivedNoteFormData) => {
try {
setIsSubmitting(true);
setError(null);

if (!token) {
throw new Error("No authentication token found");
}

const grnData = {
businessId: currentBusiness.id,
purchaseOrderId: formData.purchaseOrderId,
locationId: currentLocation.id,
locationName: currentLocation.name,
receivedByUserId: user.id,
receivedDate: formData.receivedDate.toISOString(),
goodsReceivedDetail: {
items: formData.items.map((item) => ({
...item,
total: item.receivedQuantity * item.unitPrice,
})),
},
note: formData.note,
createdBy: user.id,
};

const newGrn = await createGoodsReceivedNote(token, grnData);
setCreatedGrn(newGrn);
toast({ description: t("goodsReceivedNote.successCreate") });
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to create goods received note"
);
} finally {
setIsSubmitting(false);
}
};

const handleSubmitGrn = async (grnId: string) => {
try {
setIsSubmitting(true);
await submitGoodsReceivedNote(token!, grnId);
toast({ description: t("goodsReceivedNote.successSubmit") });
// Refresh or redirect
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to submit goods received note"
);
} finally {
setIsSubmitting(false);
}
};

if (isUserLoading) {
return <LoadingSpinner message={t("common.loadingUser")} />;
}

if (error) {
return <ErrorDisplay error={error} />;
}

if (userError) {
return (
<ErrorMessage
message={`${t("common.failedToLoadUser")}: ${userError?.message ?? ""}`}
/>
);
}

return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">
{t("goodsReceivedNote.title")}
</h1>

<GoodsReceivedNoteForm
onSubmit={handleSubmit}
onSubmitGrn={handleSubmitGrn}
isSubmitting={isSubmitting}
createdGrn={createdGrn}
/>
</div>
</div>
);
}

Component Features:

  1. State Management:

    • isSubmitting: Tracks form submission state
    • error: Stores error messages for display
    • createdGrn: Stores the newly created GRN for confirmation
  2. Context Integration:

    • Uses useAuth() for authentication token
    • Uses useCurrentBusiness() for business context
    • Uses useCurrentLocation() for location context
    • Uses useFetchUser() for user information
    • Uses useToast() for user notifications
  3. Form Handlers:

    • handleSubmit: Creates new GRN with proper data transformation
    • handleSubmitGrn: Submits existing GRN to update inventory
  4. Error Handling:

    • Displays loading states during user fetch
    • Shows error messages for failed operations
    • Handles authentication errors gracefully
  5. Data Transformation:

    • Converts form data to API format
    • Adds business and location context
    • Calculates totals for received items
    • Formats dates for API consumption

Integration Points:

  • Purchase Order Integration: Receives purchase order ID from URL params
  • Inventory System: Triggers inventory updates on GRN submission
  • User Management: Uses current user for audit trail
  • Business Context: Ensures proper business isolation
  • Location Context: Associates GRN with specific location

Routing Integration:

// In App.tsx or routing configuration
{
path: "/goods-received-notes/:purchaseOrderId",
element: <GoodsReceivedNotePage />,
}

Translation Keys Required:

{
"goodsReceivedNote": {
"title": "Goods Received Note",
"successCreate": "Goods received note created successfully",
"successSubmit": "Goods received note submitted successfully"
},
"common": {
"loadingUser": "Loading user information...",
"failedToLoadUser": "Failed to load user information",
"saving": "Saving..."
}
}

Testing Considerations:

  • Unit tests for form submission logic
  • Integration tests with purchase order data
  • Error handling scenarios
  • Loading state management
  • Context integration validation
3.3.2 Form Component

File: apps/frontend-pwa/src/components/forms/goods-received-note/GoodsReceivedNoteForm.tsx

import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { getPurchaseOrderById } from "@/services/purchaseOrderService";
import { getGoodsReceivedNotesByPurchaseOrder } from "@/services/goodsReceivedNoteService";
import { PurchaseOrder } from "@/types/purchaseOrder";
import {
GoodsReceivedNote,
GoodsReceivedNoteFormData,
} from "@/types/goodsReceivedNote";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";

interface GoodsReceivedNoteFormProps {
onSubmit: (data: GoodsReceivedNoteFormData) => Promise<void>;
onSubmitGrn: (grnId: string) => Promise<void>;
isSubmitting: boolean;
createdGrn: GoodsReceivedNote | null;
}

export function GoodsReceivedNoteForm({
onSubmit,
onSubmitGrn,
isSubmitting,
createdGrn,
}: GoodsReceivedNoteFormProps) {
const { t } = useTranslation();
const { purchaseOrderId } = useParams<{ purchaseOrderId: string }>();

const [purchaseOrder, setPurchaseOrder] = useState<PurchaseOrder | null>(
null
);
const [existingGrns, setExistingGrns] = useState<GoodsReceivedNote[]>([]);
const [formData, setFormData] = useState<GoodsReceivedNoteFormData>({
purchaseOrderId: purchaseOrderId || "",
locationId: "",
receivedDate: new Date(),
items: [],
note: "",
});

useEffect(() => {
if (purchaseOrderId) {
loadPurchaseOrder();
loadExistingGrns();
}
}, [purchaseOrderId]);

const loadPurchaseOrder = async () => {
try {
const po = await getPurchaseOrderById(token!, purchaseOrderId!);
setPurchaseOrder(po);

// Initialize form with PO items
const items = po.purchaseDetail.items.map((item) => ({
productId: item.productId,
product: item.product,
orderedQuantity: item.quantity,
receivedQuantity: 0,
damagedQuantity: 0,
unitPrice: item.unitPrice,
note: "",
}));

setFormData((prev) => ({
...prev,
items,
locationId: po.locationId,
}));
} catch (error) {
console.error("Failed to load purchase order:", error);
}
};

const loadExistingGrns = async () => {
try {
const grns = await getGoodsReceivedNotesByPurchaseOrder(
token!,
purchaseOrderId!
);
setExistingGrns(grns);
} catch (error) {
console.error("Failed to load existing GRNs:", error);
}
};

const handleItemChange = (
index: number,
field: keyof GoodsReceivedItem,
value: any
) => {
setFormData((prev) => ({
...prev,
items: prev.items.map((item, i) =>
i === index ? { ...item, [field]: value } : item
),
}));
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};

if (!purchaseOrder) {
return <LoadingSpinner message={t("common.loading")} />;
}

return (
<div className="space-y-6">
{/* Purchase Order Summary */}
<Card>
<CardHeader>
<CardTitle>{t("goodsReceivedNote.purchaseOrderSummary")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>{t("purchaseOrder.documentNumber")}</Label>
<p>{purchaseOrder.documentNumber}</p>
</div>
<div>
<Label>{t("purchaseOrder.supplier")}</Label>
<p>{purchaseOrder.taxName}</p>
</div>
<div>
<Label>{t("purchaseOrder.orderDate")}</Label>
<p>{new Date(purchaseOrder.orderDate).toLocaleDateString()}</p>
</div>
<div>
<Label>{t("purchaseOrder.deliveryDate")}</Label>
<p>{new Date(purchaseOrder.deliveryDate).toLocaleDateString()}</p>
</div>
</div>
</CardContent>
</Card>

{/* Existing GRNs */}
{existingGrns.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t("goodsReceivedNote.existingGrns")}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("goodsReceivedNote.documentNumber")}</TableHead>
<TableHead>{t("goodsReceivedNote.receivedDate")}</TableHead>
<TableHead>{t("goodsReceivedNote.status")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{existingGrns.map((grn) => (
<TableRow key={grn.id}>
<TableCell>{grn.documentNumber}</TableCell>
<TableCell>
{new Date(grn.receivedDate).toLocaleDateString()}
</TableCell>
<TableCell>{grn.status}</TableCell>
<TableCell>
{grn.status === "draft" && (
<Button
onClick={() => onSubmitGrn(grn.id!)}
disabled={isSubmitting}
>
{t("goodsReceivedNote.submit")}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}

{/* GRN Form */}
<Card>
<CardHeader>
<CardTitle>{t("goodsReceivedNote.createNew")}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="receivedDate">
{t("goodsReceivedNote.receivedDate")}
</Label>
<Input
id="receivedDate"
type="date"
value={formData.receivedDate.toISOString().split("T")[0]}
onChange={(e) =>
setFormData((prev) => ({
...prev,
receivedDate: new Date(e.target.value),
}))
}
required
/>
</div>
</div>

{/* Items Table */}
<div>
<Label>{t("goodsReceivedNote.items")}</Label>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("product.name")}</TableHead>
<TableHead>{t("goodsReceivedNote.orderedQty")}</TableHead>
<TableHead>{t("goodsReceivedNote.receivedQty")}</TableHead>
<TableHead>{t("goodsReceivedNote.damagedQty")}</TableHead>
<TableHead>{t("goodsReceivedNote.unitPrice")}</TableHead>
<TableHead>{t("goodsReceivedNote.note")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formData.items.map((item, index) => (
<TableRow key={index}>
<TableCell>
{item.product?.name || item.productId}
</TableCell>
<TableCell>{item.orderedQuantity}</TableCell>
<TableCell>
<Input
type="number"
min="0"
max={item.orderedQuantity}
value={item.receivedQuantity}
onChange={(e) =>
handleItemChange(
index,
"receivedQuantity",
Number(e.target.value)
)
}
/>
</TableCell>
<TableCell>
<Input
type="number"
min="0"
value={item.damagedQuantity || 0}
onChange={(e) =>
handleItemChange(
index,
"damagedQuantity",
Number(e.target.value)
)
}
/>
</TableCell>
<TableCell>{item.unitPrice}</TableCell>
<TableCell>
<Input
value={item.note || ""}
onChange={(e) =>
handleItemChange(index, "note", e.target.value)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>

<div>
<Label htmlFor="note">{t("goodsReceivedNote.note")}</Label>
<Textarea
id="note"
value={formData.note || ""}
onChange={(e) =>
setFormData((prev) => ({ ...prev, note: e.target.value }))
}
/>
</div>

<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? t("common.saving")
: t("goodsReceivedNote.create")}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

Phase 4: Integration & Updates

4.1 Update Purchase Orders Module

Add GRN endpoints to purchase orders controller:

// In purchase-orders.controller.ts
@Get(":id/goods-received-notes")
async getGoodsReceivedNotesByPurchaseOrder(
@Param("id") id: string,
): Promise<SelectableGoodsReceivedNote[]> {
return this.goodsReceivedNotesService.findByPurchaseOrderId(id);
}

4.2 Update App Module

File: apps/backend/src/app.module.ts

// Add import
import { GoodsReceivedNotesModule } from "@/goods-received-notes/goods-received-notes.module";

// Add to imports array
@Module({
imports: [
// ... existing modules
GoodsReceivedNotesModule,
],
})
export class AppModule {}

4.3 Add Routes

File: apps/frontend-pwa/src/App.tsx (or routing configuration)

// Add new route
{
path: "/goods-received-notes/:purchaseOrderId",
element: <GoodsReceivedNotePage />,
},
{
path: "/goods-received-notes",
element: <GoodsReceivedNotesListPage />,
},

Phase 5: Testing & Validation

5.1 Unit Tests

  • Repository tests for CRUD operations
  • Service tests for business logic
  • Controller tests for API endpoints
  • Form validation tests

5.2 Integration Tests

  • End-to-end GRN creation flow
  • Inventory update validation
  • Purchase order integration

5.3 Manual Testing

  • Create GRN from purchase order
  • Submit GRN and verify inventory updates
  • Test partial receipts
  • Test damaged items handling
  • Verify audit trail

Phase 6: Documentation & Deployment

6.1 API Documentation

  • OpenAPI/Swagger documentation
  • Postman collection
  • Integration examples

6.2 User Documentation

  • User guide for GRN creation
  • Workflow documentation
  • Troubleshooting guide

6.3 Deployment

  • Database migration scripts
  • Environment configuration
  • Monitoring and logging setup

Implementation Timeline

Week 1: Database & Backend Foundation

  • Database migration and types
  • Repository and service layer
  • Basic CRUD operations

Week 2: Backend Business Logic

  • Inventory integration
  • Status management
  • Event handling

Week 3: Frontend Foundation

  • Types and services
  • Basic form components
  • Integration with purchase orders

Week 4: Frontend Polish & Testing

  • Complete UI implementation
  • Form validation
  • Unit and integration tests

Week 5: Integration & Documentation

  • End-to-end testing
  • Documentation
  • Deployment preparation

Success Criteria

  1. Functional Requirements

    • Create GRN from purchase order
    • Track received vs ordered quantities
    • Update inventory on submission
    • Handle partial receipts
    • Support damaged items
  2. Technical Requirements

    • Clean architecture compliance
    • Type safety throughout
    • Proper error handling
    • Audit trail maintenance
    • Performance optimization
  3. User Experience

    • Intuitive form interface
    • Clear status indicators
    • Responsive design
    • Accessibility compliance

Risk Mitigation

  1. Data Integrity

    • Database constraints and validation
    • Transaction-based operations
    • Rollback mechanisms
  2. Performance

    • Database indexing
    • Pagination for large datasets
    • Caching strategies
  3. User Adoption

    • Comprehensive training materials
    • Phased rollout
    • Feedback collection

Future Enhancements

  1. Advanced Features

    • Barcode scanning integration
    • Mobile app support
    • Email notifications
    • Advanced reporting
  2. Integration

    • Supplier portal integration
    • Accounting system integration
    • Warehouse management system
  3. Analytics

    • Receipt performance metrics
    • Supplier performance tracking
    • Inventory accuracy reports