MukeshKapoor25 commited on
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 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=False, index=True)
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
- SELECT *
134
- FROM trans.fn_get_po_items_for_purchase_return(
135
- CAST(:po_id AS uuid),
136
- CAST(:warehouse_id AS uuid)
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
- catalogue_name=row["catalogue_name"],
 
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
- where_conditions.append("p.status = :status")
340
- params["status"] = status_filter
 
 
 
 
 
 
 
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, 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 uuid)
8
- RETURNS TABLE(po_item_id uuid, catalogue_id uuid, catalogue_name text, received_qty numeric, returned_qty numeric, dispatched_qty numeric, returnable_qty numeric, batch_no text, expiry_date date, qty_available numeric, cost_price numeric)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, 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="SKU_TEST_RETURN",
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
- po = await orders_service.create_po(po_create)
69
- print(f"βœ… PO Created: {po.po_no} ({po.po_id})")
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(str(po.po_id), status_change)
79
  # Then Approve
80
- await orders_service.approve_po(str(po.po_id), status_change)
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(str(po.po_id), include_items=True)
87
- po_item_id = po_dict["items"][0]["po_item_id"]
 
 
 
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="WH_MAIN",
94
  status="received",
95
- total_qty=Decimal("10.000"),
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=catalogue_id,
102
- sku="SKU_TEST_RETURN",
103
  recv_qty=Decimal("10.000"),
 
104
  acc_qty=Decimal("10.000"),
105
- rej_qty=Decimal("0.000"),
106
  uom="PCS",
107
- batch_no="BATCH_RETURN_001",
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="WH_MAIN"
122
  )
123
  print(f"Items available: {len(items_for_return)}")
124
  for item in items_for_return:
125
- print(f" - Item: {item.catalogue_name}, Returnable: {item.returnable_qty}, Batch: {item.batch_no}")
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="WH_MAIN",
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}")