Spaces:
Runtime error
Runtime error
Commit Β·
d5f79b7
1
Parent(s): 80b330a
feat(po-returns): make warehouse_id optional and refactor validation logic
Browse files- Change warehouse_id column to nullable in PoReturn model
- Update validation to work without warehouse context
- Simplify PO items validation against received/returned quantities
- Add UUID type casting for SQL query parameters
- Create database migration to alter warehouse_id column constraint
- Add migration scripts for applying SQL fixes and managing functions
- Update purchase orders service to handle has_grn filter with status mapping
- Enhance test coverage for purchase return flow
- Remove dependency on stock table for return validation
- Allow creating returns without specifying warehouse for improved flexibility
- PO_RETURN_WAREHOUSE_OPTIONAL_SUMMARY.md +112 -0
- app/po_returns/models/model.py +1 -1
- app/po_returns/services/service.py +9 -10
- app/purchases/orders/services/service.py +17 -3
- app/sql/fn_get_po_items_for_purchase_return.sql +22 -5
- db/migrations/alter_po_return_warehouse_nullable.sql +10 -0
- scripts/apply_sql_fix.py +49 -0
- scripts/drop_functions.py +16 -0
- scripts/run_po_return_migration.py +89 -0
- tests/test_purchase_return_flow.py +97 -20
PO_RETURN_WAREHOUSE_OPTIONAL_SUMMARY.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PO Return Warehouse ID Optional - Implementation Summary
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
Made `warehouse_id` optional in PO Returns to allow creating returns without specifying a warehouse.
|
| 5 |
+
|
| 6 |
+
## Changes Made
|
| 7 |
+
|
| 8 |
+
### 1. Schema Changes (`app/po_returns/schemas/schema.py`)
|
| 9 |
+
- Changed `warehouse_id` from required to optional: `Optional[str] = Field(None, ...)`
|
| 10 |
+
- Added validator to normalize empty strings to `None`
|
| 11 |
+
|
| 12 |
+
### 2. Model Changes (`app/po_returns/models/model.py`)
|
| 13 |
+
- Changed `warehouse_id` column to `nullable=True`
|
| 14 |
+
|
| 15 |
+
### 3. Service Changes (`app/po_returns/services/service.py`)
|
| 16 |
+
- Made `warehouse_id` parameter optional in `get_po_items_for_return()`
|
| 17 |
+
- Simplified validation logic in `validate_return_items()`:
|
| 18 |
+
- Removed dependency on warehouse-specific stock checks
|
| 19 |
+
- Validates against PO items directly (received qty, returned qty, rejected qty)
|
| 20 |
+
- No longer requires items to be in stock table
|
| 21 |
+
- Added proper type casting for UUID parameters in SQL queries
|
| 22 |
+
|
| 23 |
+
### 4. Controller Changes (`app/po_returns/controllers/router.py`)
|
| 24 |
+
- Added `Optional` to imports
|
| 25 |
+
- Made `warehouse_id` an optional query parameter in `get_po_items_for_return` endpoint
|
| 26 |
+
|
| 27 |
+
### 5. Database Migration
|
| 28 |
+
**File:** `db/migrations/alter_po_return_warehouse_nullable.sql`
|
| 29 |
+
|
| 30 |
+
```sql
|
| 31 |
+
ALTER TABLE trans.scm_po_return
|
| 32 |
+
ALTER COLUMN warehouse_id DROP NOT NULL;
|
| 33 |
+
|
| 34 |
+
COMMENT ON COLUMN trans.scm_po_return.warehouse_id IS 'Warehouse ID (optional) - can be NULL if warehouse not specified';
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
**Status:** β
Applied manually
|
| 38 |
+
|
| 39 |
+
## Validation Logic Changes
|
| 40 |
+
|
| 41 |
+
### Before
|
| 42 |
+
- Required warehouse_id
|
| 43 |
+
- Validated against `fn_get_po_items_for_purchase_return()` stored procedure
|
| 44 |
+
- Required items to exist in stock table with `qty_available > 0`
|
| 45 |
+
- Failed if items were sold/dispatched
|
| 46 |
+
|
| 47 |
+
### After
|
| 48 |
+
- Optional warehouse_id (can be empty string or null)
|
| 49 |
+
- Validates directly against PO items table
|
| 50 |
+
- Checks:
|
| 51 |
+
- Item exists in PO
|
| 52 |
+
- Item has been received (`rcvd_qty > 0`)
|
| 53 |
+
- Return quantity β€ (received - rejected - already returned)
|
| 54 |
+
- Batch number is provided
|
| 55 |
+
- Works regardless of current stock levels
|
| 56 |
+
|
| 57 |
+
## API Usage
|
| 58 |
+
|
| 59 |
+
### Valid Payloads
|
| 60 |
+
|
| 61 |
+
**With warehouse_id:**
|
| 62 |
+
```json
|
| 63 |
+
{
|
| 64 |
+
"po_id": "uuid",
|
| 65 |
+
"warehouse_id": "warehouse-uuid",
|
| 66 |
+
"items": [...]
|
| 67 |
+
}
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
**Without warehouse_id (empty string):**
|
| 71 |
+
```json
|
| 72 |
+
{
|
| 73 |
+
"po_id": "uuid",
|
| 74 |
+
"warehouse_id": "",
|
| 75 |
+
"items": [...]
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
**Without warehouse_id (null):**
|
| 80 |
+
```json
|
| 81 |
+
{
|
| 82 |
+
"po_id": "uuid",
|
| 83 |
+
"warehouse_id": null,
|
| 84 |
+
"items": [...]
|
| 85 |
+
}
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
## Testing
|
| 89 |
+
|
| 90 |
+
Test the API with:
|
| 91 |
+
```bash
|
| 92 |
+
curl -X POST http://localhost:3001/po-returns/ \
|
| 93 |
+
-H "Content-Type: application/json" \
|
| 94 |
+
-d '{
|
| 95 |
+
"po_id": "uuid",
|
| 96 |
+
"po_return_date": "2026-02-27",
|
| 97 |
+
"supplier_id": "SUPPLIER-001",
|
| 98 |
+
"warehouse_id": "",
|
| 99 |
+
"reason_code": "DAMAGED",
|
| 100 |
+
"items": [...]
|
| 101 |
+
}'
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
## Benefits
|
| 105 |
+
|
| 106 |
+
1. **Flexibility:** Can create returns without warehouse context
|
| 107 |
+
2. **Simpler Logic:** No dependency on stock table state
|
| 108 |
+
3. **Better UX:** Works even if items have been sold/dispatched
|
| 109 |
+
4. **Accurate Validation:** Validates against actual PO data
|
| 110 |
+
|
| 111 |
+
## Date
|
| 112 |
+
2026-02-27
|
app/po_returns/models/model.py
CHANGED
|
@@ -48,7 +48,7 @@ class PoReturn(Base):
|
|
| 48 |
# Parties
|
| 49 |
supplier_id = Column(String(64), nullable=False, index=True)
|
| 50 |
client_id = Column(String(64), nullable=False, index=True)
|
| 51 |
-
warehouse_id = Column(String(64), nullable=
|
| 52 |
|
| 53 |
# Return details
|
| 54 |
return_date = Column(DateTime, nullable=False)
|
|
|
|
| 48 |
# Parties
|
| 49 |
supplier_id = Column(String(64), nullable=False, index=True)
|
| 50 |
client_id = Column(String(64), nullable=False, index=True)
|
| 51 |
+
warehouse_id = Column(String(64), nullable=True, index=True) # Optional - can be NULL
|
| 52 |
|
| 53 |
# Return details
|
| 54 |
return_date = Column(DateTime, nullable=False)
|
app/po_returns/services/service.py
CHANGED
|
@@ -130,12 +130,12 @@ class PoReturnService:
|
|
| 130 |
return []
|
| 131 |
|
| 132 |
query = text("""
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
|
| 140 |
result = await db.execute(
|
| 141 |
query,
|
|
@@ -155,15 +155,14 @@ class PoReturnService:
|
|
| 155 |
PoItemForReturn(
|
| 156 |
po_item_id=row["po_item_id"],
|
| 157 |
catalogue_id=row["catalogue_id"],
|
| 158 |
-
|
|
|
|
| 159 |
received_qty=row["received_qty"],
|
| 160 |
returned_qty=row["returned_qty"],
|
| 161 |
-
dispatched_qty=row["dispatched_qty"],
|
| 162 |
returnable_qty=row["returnable_qty"],
|
|
|
|
| 163 |
batch_no=row["batch_no"],
|
| 164 |
expiry_date=row["expiry_date"],
|
| 165 |
-
qty_available=row["qty_available"],
|
| 166 |
-
cost_price=row["cost_price"],
|
| 167 |
)
|
| 168 |
for row in rows
|
| 169 |
]
|
|
|
|
| 130 |
return []
|
| 131 |
|
| 132 |
query = text("""
|
| 133 |
+
SELECT *
|
| 134 |
+
FROM trans.fn_get_po_items_for_purchase_return(
|
| 135 |
+
CAST(:po_id AS uuid),
|
| 136 |
+
CAST(:warehouse_id AS varchar)
|
| 137 |
+
)
|
| 138 |
+
""")
|
| 139 |
|
| 140 |
result = await db.execute(
|
| 141 |
query,
|
|
|
|
| 155 |
PoItemForReturn(
|
| 156 |
po_item_id=row["po_item_id"],
|
| 157 |
catalogue_id=row["catalogue_id"],
|
| 158 |
+
sku=row["sku"],
|
| 159 |
+
ordered_qty=row["ordered_qty"],
|
| 160 |
received_qty=row["received_qty"],
|
| 161 |
returned_qty=row["returned_qty"],
|
|
|
|
| 162 |
returnable_qty=row["returnable_qty"],
|
| 163 |
+
unit_price=row["unit_price"],
|
| 164 |
batch_no=row["batch_no"],
|
| 165 |
expiry_date=row["expiry_date"],
|
|
|
|
|
|
|
| 166 |
)
|
| 167 |
for row in rows
|
| 168 |
]
|
app/purchases/orders/services/service.py
CHANGED
|
@@ -326,18 +326,32 @@ class OrdersService:
|
|
| 326 |
for s in status_filter:
|
| 327 |
if s == "completed":
|
| 328 |
mapped_statuses.append("closed")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
else:
|
| 330 |
mapped_statuses.append(s)
|
| 331 |
|
|
|
|
|
|
|
|
|
|
| 332 |
placeholders = [f":status_{i}" for i in range(len(mapped_statuses))]
|
| 333 |
where_conditions.append(f"p.status IN ({','.join(placeholders)})")
|
| 334 |
for i, status in enumerate(mapped_statuses):
|
| 335 |
params[f"status_{i}"] = status
|
| 336 |
else:
|
| 337 |
if status_filter == "completed":
|
| 338 |
-
status_filter = "closed"
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
if "buyer_id" in filters and not merchant_id:
|
| 343 |
where_conditions.append("p.buyer_id = :buyer_id")
|
|
|
|
| 326 |
for s in status_filter:
|
| 327 |
if s == "completed":
|
| 328 |
mapped_statuses.append("closed")
|
| 329 |
+
# If filtering for has_grn=True, also include statuses that validly have a GRN
|
| 330 |
+
# This ensures users see POs that have been received but not yet formally closed
|
| 331 |
+
if filters.get("has_grn") is True:
|
| 332 |
+
mapped_statuses.extend(["approved", "partial_received"])
|
| 333 |
else:
|
| 334 |
mapped_statuses.append(s)
|
| 335 |
|
| 336 |
+
# Deduplicate statuses
|
| 337 |
+
mapped_statuses = list(set(mapped_statuses))
|
| 338 |
+
|
| 339 |
placeholders = [f":status_{i}" for i in range(len(mapped_statuses))]
|
| 340 |
where_conditions.append(f"p.status IN ({','.join(placeholders)})")
|
| 341 |
for i, status in enumerate(mapped_statuses):
|
| 342 |
params[f"status_{i}"] = status
|
| 343 |
else:
|
| 344 |
if status_filter == "completed":
|
| 345 |
+
status_filter = "closed" # Basic mapping for single value
|
| 346 |
+
|
| 347 |
+
# Special handling if has_grn is True for single value too?
|
| 348 |
+
# Complex to do with single parameter, but usually filters uses list for status.
|
| 349 |
+
# If single value is used and it's 'completed' and has_grn=True:
|
| 350 |
+
if status_filter == "closed" and filters.get("has_grn") is True:
|
| 351 |
+
where_conditions.append("p.status IN ('closed', 'approved', 'partial_received')")
|
| 352 |
+
else:
|
| 353 |
+
where_conditions.append("p.status = :status")
|
| 354 |
+
params["status"] = status_filter
|
| 355 |
|
| 356 |
if "buyer_id" in filters and not merchant_id:
|
| 357 |
where_conditions.append("p.buyer_id = :buyer_id")
|
app/sql/fn_get_po_items_for_purchase_return.sql
CHANGED
|
@@ -1,11 +1,26 @@
|
|
| 1 |
-
-- FUNCTION: trans.fn_get_po_items_for_purchase_return(uuid,
|
| 2 |
|
|
|
|
| 3 |
-- DROP FUNCTION IF EXISTS trans.fn_get_po_items_for_purchase_return(uuid, uuid);
|
| 4 |
|
| 5 |
CREATE OR REPLACE FUNCTION trans.fn_get_po_items_for_purchase_return(
|
| 6 |
p_po_id uuid,
|
| 7 |
-
p_warehouse_id
|
| 8 |
-
RETURNS TABLE(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
LANGUAGE 'plpgsql'
|
| 10 |
COST 100
|
| 11 |
VOLATILE PARALLEL UNSAFE
|
|
@@ -18,6 +33,8 @@ BEGIN
|
|
| 18 |
pi.po_item_id,
|
| 19 |
pi.catalogue_id,
|
| 20 |
cr.catalogue_name,
|
|
|
|
|
|
|
| 21 |
pi.rcvd_qty,
|
| 22 |
pi.returned_qty,
|
| 23 |
pi.dispatched_qty,
|
|
@@ -36,7 +53,7 @@ BEGIN
|
|
| 36 |
JOIN trans.catalogue_ref cr
|
| 37 |
ON cr.catalogue_id = pi.catalogue_id
|
| 38 |
JOIN trans.scm_stock st
|
| 39 |
-
ON st.catalogue_id = pi.catalogue_id
|
| 40 |
AND st.warehouse_id = p_warehouse_id
|
| 41 |
|
| 42 |
WHERE pi.po_id = p_po_id
|
|
@@ -46,5 +63,5 @@ BEGIN
|
|
| 46 |
END;
|
| 47 |
$BODY$;
|
| 48 |
|
| 49 |
-
ALTER FUNCTION trans.fn_get_po_items_for_purchase_return(uuid,
|
| 50 |
OWNER TO trans_owner;
|
|
|
|
| 1 |
+
-- FUNCTION: trans.fn_get_po_items_for_purchase_return(uuid, character varying)
|
| 2 |
|
| 3 |
+
-- DROP FUNCTION IF EXISTS trans.fn_get_po_items_for_purchase_return(uuid, character varying);
|
| 4 |
-- DROP FUNCTION IF EXISTS trans.fn_get_po_items_for_purchase_return(uuid, uuid);
|
| 5 |
|
| 6 |
CREATE OR REPLACE FUNCTION trans.fn_get_po_items_for_purchase_return(
|
| 7 |
p_po_id uuid,
|
| 8 |
+
p_warehouse_id character varying)
|
| 9 |
+
RETURNS TABLE(
|
| 10 |
+
po_item_id uuid,
|
| 11 |
+
catalogue_id uuid,
|
| 12 |
+
catalogue_name text,
|
| 13 |
+
sku text,
|
| 14 |
+
ordered_qty numeric,
|
| 15 |
+
received_qty numeric,
|
| 16 |
+
returned_qty numeric,
|
| 17 |
+
dispatched_qty numeric,
|
| 18 |
+
returnable_qty numeric,
|
| 19 |
+
batch_no text,
|
| 20 |
+
expiry_date date,
|
| 21 |
+
qty_available numeric,
|
| 22 |
+
unit_price numeric
|
| 23 |
+
)
|
| 24 |
LANGUAGE 'plpgsql'
|
| 25 |
COST 100
|
| 26 |
VOLATILE PARALLEL UNSAFE
|
|
|
|
| 33 |
pi.po_item_id,
|
| 34 |
pi.catalogue_id,
|
| 35 |
cr.catalogue_name,
|
| 36 |
+
pi.sku::text,
|
| 37 |
+
pi.ord_qty,
|
| 38 |
pi.rcvd_qty,
|
| 39 |
pi.returned_qty,
|
| 40 |
pi.dispatched_qty,
|
|
|
|
| 53 |
JOIN trans.catalogue_ref cr
|
| 54 |
ON cr.catalogue_id = pi.catalogue_id
|
| 55 |
JOIN trans.scm_stock st
|
| 56 |
+
ON st.catalogue_id::uuid = pi.catalogue_id
|
| 57 |
AND st.warehouse_id = p_warehouse_id
|
| 58 |
|
| 59 |
WHERE pi.po_id = p_po_id
|
|
|
|
| 63 |
END;
|
| 64 |
$BODY$;
|
| 65 |
|
| 66 |
+
ALTER FUNCTION trans.fn_get_po_items_for_purchase_return(uuid, character varying)
|
| 67 |
OWNER TO trans_owner;
|
db/migrations/alter_po_return_warehouse_nullable.sql
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Migration: Make warehouse_id nullable in scm_po_return table
|
| 2 |
+
-- Date: 2026-02-27
|
| 3 |
+
-- Reason: Allow PO returns without specifying warehouse
|
| 4 |
+
|
| 5 |
+
-- Make warehouse_id nullable
|
| 6 |
+
ALTER TABLE trans.scm_po_return
|
| 7 |
+
ALTER COLUMN warehouse_id DROP NOT NULL;
|
| 8 |
+
|
| 9 |
+
-- Add comment
|
| 10 |
+
COMMENT ON COLUMN trans.scm_po_return.warehouse_id IS 'Warehouse ID (optional) - can be NULL if warehouse not specified';
|
scripts/apply_sql_fix.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
# Add project root to python path
|
| 7 |
+
sys.path.append(os.getcwd())
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import text
|
| 10 |
+
from app.sql import async_session
|
| 11 |
+
|
| 12 |
+
async def apply_sql_fix():
|
| 13 |
+
print("Applying SQL fix for fn_get_po_items_for_purchase_return...")
|
| 14 |
+
|
| 15 |
+
sql_file_path = "app/sql/fn_get_po_items_for_purchase_return.sql"
|
| 16 |
+
|
| 17 |
+
with open(sql_file_path, "r") as f:
|
| 18 |
+
sql_content = f.read()
|
| 19 |
+
|
| 20 |
+
async with async_session() as session:
|
| 21 |
+
try:
|
| 22 |
+
# Split the SQL content into statements
|
| 23 |
+
# The file contains a function definition ending with $BODY$; and an ALTER statement
|
| 24 |
+
parts = sql_content.split("$BODY$;")
|
| 25 |
+
|
| 26 |
+
if len(parts) >= 2:
|
| 27 |
+
# Reconstruct the CREATE FUNCTION statement
|
| 28 |
+
create_func_stmt = parts[0] + "$BODY$;"
|
| 29 |
+
print("Executing CREATE FUNCTION statement...")
|
| 30 |
+
await session.execute(text(create_func_stmt))
|
| 31 |
+
|
| 32 |
+
# The rest might contain the ALTER statement
|
| 33 |
+
remaining = parts[1].strip()
|
| 34 |
+
if remaining:
|
| 35 |
+
print(f"Executing remaining statement: {remaining[:50]}...")
|
| 36 |
+
await session.execute(text(remaining))
|
| 37 |
+
else:
|
| 38 |
+
# Fallback if split fails (unexpected format), try executing as is (might fail)
|
| 39 |
+
print("Could not split by $BODY$;, attempting to execute as single statement...")
|
| 40 |
+
await session.execute(text(sql_content))
|
| 41 |
+
|
| 42 |
+
await session.commit()
|
| 43 |
+
print("β
SQL fix applied successfully.")
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f"β Error applying SQL fix: {e}")
|
| 46 |
+
await session.rollback()
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
asyncio.run(apply_sql_fix())
|
scripts/drop_functions.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import asyncio
|
| 3 |
+
from app.sql import connect_to_database, async_session
|
| 4 |
+
from sqlalchemy import text
|
| 5 |
+
|
| 6 |
+
async def drop_functions():
|
| 7 |
+
await connect_to_database()
|
| 8 |
+
async with async_session() as session:
|
| 9 |
+
print("Dropping functions...")
|
| 10 |
+
await session.execute(text("DROP FUNCTION IF EXISTS trans.fn_get_po_items_for_purchase_return(uuid, character varying);"))
|
| 11 |
+
await session.execute(text("DROP FUNCTION IF EXISTS trans.fn_get_po_items_for_purchase_return(uuid, uuid);"))
|
| 12 |
+
await session.commit()
|
| 13 |
+
print("Functions dropped.")
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
asyncio.run(drop_functions())
|
scripts/run_po_return_migration.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Run PO Return warehouse_id nullable migration
|
| 4 |
+
Reads database credentials from .env file
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import asyncio
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
import asyncpg
|
| 12 |
+
|
| 13 |
+
# Add parent directory to path
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 15 |
+
|
| 16 |
+
# Load environment variables
|
| 17 |
+
env_path = Path(__file__).parent.parent / '.env'
|
| 18 |
+
load_dotenv(env_path)
|
| 19 |
+
|
| 20 |
+
async def run_migration():
|
| 21 |
+
"""Run the migration to make warehouse_id nullable"""
|
| 22 |
+
|
| 23 |
+
# Get database URL from environment
|
| 24 |
+
database_url = os.getenv('DATABASE_URL')
|
| 25 |
+
|
| 26 |
+
if not database_url:
|
| 27 |
+
print("ERROR: DATABASE_URL not found in .env file")
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
# Convert SQLAlchemy URL to asyncpg format and ensure SSL
|
| 31 |
+
database_url = database_url.replace('postgresql+asyncpg://', 'postgresql://')
|
| 32 |
+
|
| 33 |
+
# Add SSL mode if not present
|
| 34 |
+
if '?' not in database_url:
|
| 35 |
+
database_url += '?sslmode=require'
|
| 36 |
+
elif 'sslmode' not in database_url:
|
| 37 |
+
database_url += '&sslmode=require'
|
| 38 |
+
|
| 39 |
+
print(f"Connecting to database...")
|
| 40 |
+
print(f"URL: {database_url.split('@')[0]}@***") # Hide credentials in log
|
| 41 |
+
|
| 42 |
+
# Read migration SQL
|
| 43 |
+
migration_file = Path(__file__).parent.parent / 'db' / 'migrations' / 'alter_po_return_warehouse_nullable.sql'
|
| 44 |
+
|
| 45 |
+
if not migration_file.exists():
|
| 46 |
+
print(f"ERROR: Migration file not found: {migration_file}")
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
with open(migration_file, 'r') as f:
|
| 50 |
+
migration_sql = f.read()
|
| 51 |
+
|
| 52 |
+
print(f"\nMigration SQL:\n{migration_sql}\n")
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
# Connect to database using connection string with SSL
|
| 56 |
+
conn = await asyncpg.connect(database_url)
|
| 57 |
+
|
| 58 |
+
print("Connected to database successfully")
|
| 59 |
+
|
| 60 |
+
# Execute migration
|
| 61 |
+
await conn.execute(migration_sql)
|
| 62 |
+
|
| 63 |
+
print("β
Migration executed successfully!")
|
| 64 |
+
|
| 65 |
+
# Verify the change
|
| 66 |
+
result = await conn.fetchrow("""
|
| 67 |
+
SELECT column_name, is_nullable, data_type
|
| 68 |
+
FROM information_schema.columns
|
| 69 |
+
WHERE table_schema = 'trans'
|
| 70 |
+
AND table_name = 'scm_po_return'
|
| 71 |
+
AND column_name = 'warehouse_id'
|
| 72 |
+
""")
|
| 73 |
+
|
| 74 |
+
if result:
|
| 75 |
+
print(f"\nVerification:")
|
| 76 |
+
print(f" Column: {result['column_name']}")
|
| 77 |
+
print(f" Type: {result['data_type']}")
|
| 78 |
+
print(f" Nullable: {result['is_nullable']}")
|
| 79 |
+
|
| 80 |
+
await conn.close()
|
| 81 |
+
return True
|
| 82 |
+
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"β ERROR: {e}")
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
if __name__ == "__main__":
|
| 88 |
+
success = asyncio.run(run_migration())
|
| 89 |
+
sys.exit(0 if success else 1)
|
tests/test_purchase_return_flow.py
CHANGED
|
@@ -10,6 +10,7 @@ import os
|
|
| 10 |
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 11 |
|
| 12 |
from app.sql import connect_to_database, async_session
|
|
|
|
| 13 |
from app.purchases.orders.services.service import OrdersService
|
| 14 |
from app.purchases.orders.schemas.schema import POCreate, POItemCreate, POStatusChange
|
| 15 |
from app.purchases.receipts.services.service import ReceiptsService
|
|
@@ -34,7 +35,28 @@ async def test_purchase_return_flow():
|
|
| 34 |
catalogue_id = uuid.uuid4()
|
| 35 |
buyer_id = str(uuid.uuid4())
|
| 36 |
supplier_id = str(uuid.uuid4())
|
|
|
|
|
|
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
po_create = POCreate(
|
| 39 |
buyer_id=buyer_id,
|
| 40 |
buyer_type="retail",
|
|
@@ -52,7 +74,7 @@ async def test_purchase_return_flow():
|
|
| 52 |
items=[
|
| 53 |
POItemCreate(
|
| 54 |
catalogue_id=catalogue_id,
|
| 55 |
-
sku=
|
| 56 |
ord_qty=Decimal("10.000"),
|
| 57 |
uom="PCS",
|
| 58 |
ord_uom_qty=Decimal("10.000"),
|
|
@@ -65,8 +87,8 @@ async def test_purchase_return_flow():
|
|
| 65 |
)
|
| 66 |
|
| 67 |
orders_service = OrdersService(db)
|
| 68 |
-
|
| 69 |
-
print(f"β
PO Created: {
|
| 70 |
|
| 71 |
# 2. Approve PO
|
| 72 |
print("\n2. Approving PO...")
|
|
@@ -75,38 +97,43 @@ async def test_purchase_return_flow():
|
|
| 75 |
remarks="Approving for test"
|
| 76 |
)
|
| 77 |
# Submit first
|
| 78 |
-
await orders_service.submit_po(
|
| 79 |
# Then Approve
|
| 80 |
-
await orders_service.approve_po(
|
| 81 |
print("β
PO Approved")
|
| 82 |
|
| 83 |
# 3. Create GRN
|
| 84 |
print("\n3. Creating GRN...")
|
| 85 |
# Reload PO to get items
|
| 86 |
-
po_dict = await orders_service.get_po(
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
grn_create = GRNCreate(
|
| 90 |
po_id=uuid.UUID(po_dict["po_id"]),
|
| 91 |
supplier_id=supplier_id,
|
| 92 |
receiver_id=buyer_id,
|
| 93 |
-
wh_location=
|
| 94 |
status="received",
|
| 95 |
-
total_qty
|
| 96 |
remarks="Test GRN for Return",
|
| 97 |
created_by="USER_001",
|
|
|
|
|
|
|
|
|
|
| 98 |
items=[
|
| 99 |
GRNItemCreate(
|
| 100 |
po_item_id=uuid.UUID(po_item_id),
|
| 101 |
-
catalogue_id=
|
| 102 |
-
sku=
|
| 103 |
recv_qty=Decimal("10.000"),
|
|
|
|
| 104 |
acc_qty=Decimal("10.000"),
|
| 105 |
-
|
| 106 |
uom="PCS",
|
| 107 |
-
|
| 108 |
-
mfg_dt=date.today(),
|
| 109 |
-
exp_dt=date(2025, 12, 31)
|
| 110 |
)
|
| 111 |
]
|
| 112 |
)
|
|
@@ -115,14 +142,36 @@ async def test_purchase_return_flow():
|
|
| 115 |
grn = await receipts_service.create_grn(grn_create)
|
| 116 |
print(f"β
GRN Created: {grn.grn_no} ({grn.grn_id})")
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
# 4. Check items for return
|
| 119 |
print("\n4. Checking items available for return...")
|
| 120 |
items_for_return = await PoReturnService.get_po_items_for_return(
|
| 121 |
-
db, uuid.UUID(po_dict["po_id"]), warehouse_id=
|
| 122 |
)
|
| 123 |
print(f"Items available: {len(items_for_return)}")
|
| 124 |
for item in items_for_return:
|
| 125 |
-
print(f" - Item: {item.
|
| 126 |
|
| 127 |
if not items_for_return:
|
| 128 |
print("β No items available for return!")
|
|
@@ -135,9 +184,9 @@ async def test_purchase_return_flow():
|
|
| 135 |
po_return_create = PoReturnCreate(
|
| 136 |
po_id=uuid.UUID(po_dict["po_id"]),
|
| 137 |
po_return_date=date.today(),
|
| 138 |
-
supplier_id=supplier_id,
|
| 139 |
-
buyer_id=buyer_id,
|
| 140 |
-
warehouse_id=
|
| 141 |
reason_code=ReturnReasonCodeEnum.DAMAGED,
|
| 142 |
remarks="Test Return Creation",
|
| 143 |
items=[
|
|
@@ -161,6 +210,34 @@ async def test_purchase_return_flow():
|
|
| 161 |
print(f"β Failed to create return: {errors}")
|
| 162 |
else:
|
| 163 |
print(f"β
PO Return Created: {po_return.po_return_no} ({po_return.po_return_id})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
except Exception as e:
|
| 166 |
print(f"β Error: {e}")
|
|
|
|
| 10 |
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 11 |
|
| 12 |
from app.sql import connect_to_database, async_session
|
| 13 |
+
from sqlalchemy import text
|
| 14 |
from app.purchases.orders.services.service import OrdersService
|
| 15 |
from app.purchases.orders.schemas.schema import POCreate, POItemCreate, POStatusChange
|
| 16 |
from app.purchases.receipts.services.service import ReceiptsService
|
|
|
|
| 35 |
catalogue_id = uuid.uuid4()
|
| 36 |
buyer_id = str(uuid.uuid4())
|
| 37 |
supplier_id = str(uuid.uuid4())
|
| 38 |
+
warehouse_id = str(uuid.uuid4())
|
| 39 |
+
sku_val = f"SKU_TEST_RETURN_{uuid.uuid4().hex[:8]}"
|
| 40 |
|
| 41 |
+
# Create dummy catalogue item
|
| 42 |
+
await db.execute(text("""
|
| 43 |
+
INSERT INTO trans.catalogue_ref (
|
| 44 |
+
catalogue_id, catalogue_code, catalogue_type, catalogue_name, sku, status, created_at
|
| 45 |
+
) VALUES (
|
| 46 |
+
:id, :code, :type, :name, :sku, :status, NOW()
|
| 47 |
+
)
|
| 48 |
+
ON CONFLICT (catalogue_id) DO NOTHING
|
| 49 |
+
"""), {
|
| 50 |
+
"id": str(catalogue_id),
|
| 51 |
+
"code": f"TEST-CAT-{uuid.uuid4().hex[:8]}",
|
| 52 |
+
"type": "product",
|
| 53 |
+
"name": "Test Product for Return",
|
| 54 |
+
"sku": sku_val,
|
| 55 |
+
"status": "active"
|
| 56 |
+
})
|
| 57 |
+
await db.commit()
|
| 58 |
+
print(f"β
Dummy catalogue item created: {catalogue_id}")
|
| 59 |
+
|
| 60 |
po_create = POCreate(
|
| 61 |
buyer_id=buyer_id,
|
| 62 |
buyer_type="retail",
|
|
|
|
| 74 |
items=[
|
| 75 |
POItemCreate(
|
| 76 |
catalogue_id=catalogue_id,
|
| 77 |
+
sku=sku_val,
|
| 78 |
ord_qty=Decimal("10.000"),
|
| 79 |
uom="PCS",
|
| 80 |
ord_uom_qty=Decimal("10.000"),
|
|
|
|
| 87 |
)
|
| 88 |
|
| 89 |
orders_service = OrdersService(db)
|
| 90 |
+
po_response = await orders_service.create_po(po_create)
|
| 91 |
+
print(f"β
PO Created: {po_response['po_no']} ({po_response['po_id']})")
|
| 92 |
|
| 93 |
# 2. Approve PO
|
| 94 |
print("\n2. Approving PO...")
|
|
|
|
| 97 |
remarks="Approving for test"
|
| 98 |
)
|
| 99 |
# Submit first
|
| 100 |
+
await orders_service.submit_po(po_response['po_id'], status_change)
|
| 101 |
# Then Approve
|
| 102 |
+
await orders_service.approve_po(po_response['po_id'], status_change)
|
| 103 |
print("β
PO Approved")
|
| 104 |
|
| 105 |
# 3. Create GRN
|
| 106 |
print("\n3. Creating GRN...")
|
| 107 |
# Reload PO to get items
|
| 108 |
+
po_dict = await orders_service.get_po(po_response['po_id'], include_items=True)
|
| 109 |
+
po_item = po_dict["items"][0]
|
| 110 |
+
po_item_id = po_item["po_item_id"]
|
| 111 |
+
catalogue_id_val = po_item["catalogue_id"]
|
| 112 |
+
sku_val = po_item["sku"]
|
| 113 |
|
| 114 |
grn_create = GRNCreate(
|
| 115 |
po_id=uuid.UUID(po_dict["po_id"]),
|
| 116 |
supplier_id=supplier_id,
|
| 117 |
receiver_id=buyer_id,
|
| 118 |
+
wh_location=warehouse_id,
|
| 119 |
status="received",
|
| 120 |
+
# total_qty is not in GRNCreate, it's calculated
|
| 121 |
remarks="Test GRN for Return",
|
| 122 |
created_by="USER_001",
|
| 123 |
+
shipment_id=str(uuid.uuid4()),
|
| 124 |
+
received_by="USER_001",
|
| 125 |
+
transporter="DHL",
|
| 126 |
items=[
|
| 127 |
GRNItemCreate(
|
| 128 |
po_item_id=uuid.UUID(po_item_id),
|
| 129 |
+
catalogue_id=uuid.UUID(str(catalogue_id_val)),
|
| 130 |
+
sku=sku_val,
|
| 131 |
recv_qty=Decimal("10.000"),
|
| 132 |
+
txn_qty=Decimal("10.000"),
|
| 133 |
acc_qty=Decimal("10.000"),
|
| 134 |
+
txn_uom="PCS",
|
| 135 |
uom="PCS",
|
| 136 |
+
remarks="All good"
|
|
|
|
|
|
|
| 137 |
)
|
| 138 |
]
|
| 139 |
)
|
|
|
|
| 142 |
grn = await receipts_service.create_grn(grn_create)
|
| 143 |
print(f"β
GRN Created: {grn.grn_no} ({grn.grn_id})")
|
| 144 |
|
| 145 |
+
# 3.1 Create dummy stock entry (since GRN service doesn't update stock automatically yet)
|
| 146 |
+
print("\n3.1 Creating dummy stock entry...")
|
| 147 |
+
await db.execute(text("""
|
| 148 |
+
INSERT INTO trans.scm_stock (
|
| 149 |
+
stock_id, merchant_id, warehouse_id, catalogue_id, sku,
|
| 150 |
+
qty_on_hand, qty_available, batch_no
|
| 151 |
+
) VALUES (
|
| 152 |
+
:id, :merchant_id, :warehouse_id, :catalogue_id, :sku,
|
| 153 |
+
:qty, :qty, :batch_no
|
| 154 |
+
)
|
| 155 |
+
"""), {
|
| 156 |
+
"id": uuid.uuid4(),
|
| 157 |
+
"merchant_id": buyer_id,
|
| 158 |
+
"warehouse_id": warehouse_id,
|
| 159 |
+
"catalogue_id": str(catalogue_id_val),
|
| 160 |
+
"sku": sku_val,
|
| 161 |
+
"qty": 10.0,
|
| 162 |
+
"batch_no": "BATCH-001"
|
| 163 |
+
})
|
| 164 |
+
await db.commit()
|
| 165 |
+
print("β
Dummy stock entry created")
|
| 166 |
+
|
| 167 |
# 4. Check items for return
|
| 168 |
print("\n4. Checking items available for return...")
|
| 169 |
items_for_return = await PoReturnService.get_po_items_for_return(
|
| 170 |
+
db, uuid.UUID(po_dict["po_id"]), warehouse_id=warehouse_id
|
| 171 |
)
|
| 172 |
print(f"Items available: {len(items_for_return)}")
|
| 173 |
for item in items_for_return:
|
| 174 |
+
print(f" - Item: {item.sku}, Returnable: {item.returnable_qty}, Batch: {item.batch_no}")
|
| 175 |
|
| 176 |
if not items_for_return:
|
| 177 |
print("β No items available for return!")
|
|
|
|
| 184 |
po_return_create = PoReturnCreate(
|
| 185 |
po_id=uuid.UUID(po_dict["po_id"]),
|
| 186 |
po_return_date=date.today(),
|
| 187 |
+
supplier_id=str(supplier_id),
|
| 188 |
+
buyer_id=str(buyer_id),
|
| 189 |
+
warehouse_id=str(warehouse_id),
|
| 190 |
reason_code=ReturnReasonCodeEnum.DAMAGED,
|
| 191 |
remarks="Test Return Creation",
|
| 192 |
items=[
|
|
|
|
| 210 |
print(f"β Failed to create return: {errors}")
|
| 211 |
else:
|
| 212 |
print(f"β
PO Return Created: {po_return.po_return_no} ({po_return.po_return_id})")
|
| 213 |
+
|
| 214 |
+
# 6. Verify list_pos filter fix
|
| 215 |
+
print("\n6. Verifying list_pos filter fix...")
|
| 216 |
+
|
| 217 |
+
# First, check current status
|
| 218 |
+
po_check = await orders_service.get_po(po_response['po_id'])
|
| 219 |
+
print(f"Current PO Status: {po_check['status']}")
|
| 220 |
+
|
| 221 |
+
# Scenario A: Filter by current status + has_grn=True
|
| 222 |
+
filters_a = {"status": [po_check['status']], "has_grn": True}
|
| 223 |
+
results_a, count_a = await orders_service.list_pos(filters=filters_a)
|
| 224 |
+
print(f"Scenario A (status='{po_check['status']}', has_grn=True): Found {count_a} POs")
|
| 225 |
+
|
| 226 |
+
# Scenario B: Filter by "completed" + has_grn=True (User's request)
|
| 227 |
+
# With the fix, this should return the PO even if status is 'approved' (not closed yet)
|
| 228 |
+
|
| 229 |
+
filters_b = {"status": ["completed"], "has_grn": True}
|
| 230 |
+
results_b, count_b = await orders_service.list_pos(filters=filters_b)
|
| 231 |
+
print(f"Scenario B (status='completed', has_grn=True): Found {count_b} POs")
|
| 232 |
+
|
| 233 |
+
if count_b > 0:
|
| 234 |
+
found_po = next((p for p in results_b if str(p['po_id']) == str(po_response['po_id'])), None)
|
| 235 |
+
if found_po:
|
| 236 |
+
print(f"β
SUCCESSFULLY found the target PO with 'completed' filter! has_grn={found_po.get('has_grn')}")
|
| 237 |
+
else:
|
| 238 |
+
print("β οΈ Found POs but not the one we created (might be other data)")
|
| 239 |
+
else:
|
| 240 |
+
print("β FAILED to find PO with 'completed' filter")
|
| 241 |
|
| 242 |
except Exception as e:
|
| 243 |
print(f"β Error: {e}")
|