MukeshKapoor25 commited on
Commit
0766a99
Β·
1 Parent(s): 1d54fca

Refactor COUNT queries to use COUNT(1) for improved performance and consistency across various scripts and services.

Browse files

- Updated TradeReturnService and TradeSalesService to clean filters before applying them to queries.
- Enhanced SQL queries in migration scripts and verification scripts to utilize COUNT(1) instead of COUNT(*).
- Added a new script for verifying trade invoice cleanup functionality, ensuring all constants and utility functions work as expected.
- Adjusted various test cases to reflect the changes in COUNT queries and ensure accurate assertions.

Files changed (41) hide show
  1. app/credit_debit_notes/services/service.py +24 -17
  2. app/inventory/stock/services/service.py +3 -3
  3. app/purchases/orders/services/service.py +1 -1
  4. app/trade_invoices/constants.py +78 -0
  5. app/trade_invoices/controllers/router.py +7 -7
  6. app/trade_invoices/schemas/schema.py +4 -16
  7. app/trade_invoices/services/service.py +81 -87
  8. app/trade_invoices/tests/test_cleanup_verification.py +242 -0
  9. app/trade_invoices/utils.py +166 -0
  10. app/trade_returns/services/service.py +24 -17
  11. app/trade_sales/services/service.py +59 -61
  12. check_existing_tables.py +2 -2
  13. check_stock_take_data.py +3 -3
  14. create_stock_take_tables.sql +6 -6
  15. docs/database/migrations/migration_add_inventory_column.py +1 -1
  16. docs/database/migrations/migration_move_data_to_trans_schema.py +5 -5
  17. docs/database/migrations/migration_trade_sales_tables.sql +1 -1
  18. docs/examples/ENHANCED_CATALOGUE_README.md +1 -1
  19. docs/examples/STOCK_ADJUSTMENT_ENHANCED_DESIGN.md +1 -1
  20. docs/examples/check_postgres_duplicates.py +2 -2
  21. docs/examples/clean_and_verify_sync.py +1 -1
  22. docs/examples/create_50_salon_catalogues.py +1 -1
  23. docs/examples/create_enhanced_salon_catalogues.py +1 -1
  24. docs/examples/fix_missing_postgres_records.py +1 -1
  25. docs/examples/setup_master_detail.py +1 -1
  26. docs/examples/simple_verification.py +2 -2
  27. docs/examples/sync_mongo_to_postgres.py +2 -2
  28. docs/examples/verify_enhanced_data.py +1 -1
  29. docs/examples/verify_inventory_migration.py +1 -1
  30. docs/implementation-summaries/PRICING_LEVELS_COMPLETE_SOLUTION.md +1 -1
  31. fix_schema_issues.py +1 -1
  32. migrate_tables_to_trans_schema.sql +6 -6
  33. migrate_to_master_detail.py +5 -5
  34. migrate_to_trans_schema.py +3 -3
  35. quick_fix_compatibility.py +1 -1
  36. test_simplified_stock_take_approval.py +3 -3
  37. test_stock_take_approval_workflow.py +3 -3
  38. tests/test_properties_catalogue_sync.py +2 -2
  39. tests/test_properties_employee_sync.py +2 -2
  40. tests/test_properties_merchant_sync.py +2 -2
  41. verify_trade_invoice_cleanup.py +94 -0
app/credit_debit_notes/services/service.py CHANGED
@@ -542,36 +542,43 @@ class CreditDebitNoteService:
542
  Following API standards for projection list
543
  """
544
  try:
 
 
 
 
 
 
 
545
  # Build base query
546
  query = select(CreditDebitNote)
547
 
548
  # Apply filters
549
  conditions = []
550
 
551
- if filters.get("note_type"):
552
- conditions.append(CreditDebitNote.note_type == NoteType(filters["note_type"]))
553
 
554
- if filters.get("status"):
555
- conditions.append(CreditDebitNote.status == NoteStatus(filters["status"]))
556
 
557
- if filters.get("supplier_id"):
558
- conditions.append(CreditDebitNote.supplier_id == filters["supplier_id"])
559
 
560
- if filters.get("buyer_id"):
561
- conditions.append(CreditDebitNote.buyer_id == filters["buyer_id"])
562
 
563
- if filters.get("invoice_id"):
564
- conditions.append(CreditDebitNote.invoice_id == UUID(filters["invoice_id"]))
565
 
566
- if filters.get("reason_code"):
567
- conditions.append(CreditDebitNote.reason_code == ReasonCode(filters["reason_code"]))
568
 
569
  # Date range filters
570
- if filters.get("date_from"):
571
- conditions.append(CreditDebitNote.note_date >= filters["date_from"])
572
 
573
- if filters.get("date_to"):
574
- conditions.append(CreditDebitNote.note_date <= filters["date_to"])
575
 
576
  if conditions:
577
  query = query.where(and_(*conditions))
@@ -625,7 +632,7 @@ class CreditDebitNoteService:
625
 
626
  query = text(f"""
627
  SELECT
628
- COUNT(*) as total_notes,
629
  COUNT(CASE WHEN status = 'DRAFT' THEN 1 END) as draft_count,
630
  COUNT(CASE WHEN status = 'SUBMITTED' THEN 1 END) as submitted_count,
631
  COUNT(CASE WHEN status = 'ACCEPTED' THEN 1 END) as accepted_count,
 
542
  Following API standards for projection list
543
  """
544
  try:
545
+ # Clean filters - remove None, empty strings, and empty lists
546
+ clean_filters = {}
547
+ if filters:
548
+ for key, value in filters.items():
549
+ if value is not None and value != "" and value != []:
550
+ clean_filters[key] = value
551
+
552
  # Build base query
553
  query = select(CreditDebitNote)
554
 
555
  # Apply filters
556
  conditions = []
557
 
558
+ if clean_filters.get("note_type"):
559
+ conditions.append(CreditDebitNote.note_type == NoteType(clean_filters["note_type"]))
560
 
561
+ if clean_filters.get("status"):
562
+ conditions.append(CreditDebitNote.status == NoteStatus(clean_filters["status"]))
563
 
564
+ if clean_filters.get("supplier_id"):
565
+ conditions.append(CreditDebitNote.supplier_id == clean_filters["supplier_id"])
566
 
567
+ if clean_filters.get("buyer_id"):
568
+ conditions.append(CreditDebitNote.buyer_id == clean_filters["buyer_id"])
569
 
570
+ if clean_filters.get("invoice_id"):
571
+ conditions.append(CreditDebitNote.invoice_id == UUID(clean_filters["invoice_id"]))
572
 
573
+ if clean_filters.get("reason_code"):
574
+ conditions.append(CreditDebitNote.reason_code == ReasonCode(clean_filters["reason_code"]))
575
 
576
  # Date range filters
577
+ if clean_filters.get("date_from"):
578
+ conditions.append(CreditDebitNote.note_date >= clean_filters["date_from"])
579
 
580
+ if clean_filters.get("date_to"):
581
+ conditions.append(CreditDebitNote.note_date <= clean_filters["date_to"])
582
 
583
  if conditions:
584
  query = query.where(and_(*conditions))
 
632
 
633
  query = text(f"""
634
  SELECT
635
+ COUNT(1) as total_notes,
636
  COUNT(CASE WHEN status = 'DRAFT' THEN 1 END) as draft_count,
637
  COUNT(CASE WHEN status = 'SUBMITTED' THEN 1 END) as submitted_count,
638
  COUNT(CASE WHEN status = 'ACCEPTED' THEN 1 END) as accepted_count,
app/inventory/stock/services/service.py CHANGED
@@ -476,7 +476,7 @@ class StockService:
476
  base_query += " AND " + " AND ".join(where_conditions)
477
 
478
  # Get total count
479
- count_query = f"SELECT COUNT(*) FROM ({base_query}) as count_query"
480
  count_result = await self.db.execute(text(count_query), params)
481
  total_count = count_result.scalar() or 0
482
 
@@ -576,7 +576,7 @@ class StockService:
576
  base_query += " AND " + " AND ".join(where_conditions)
577
 
578
  # Get total count
579
- count_query = f"SELECT COUNT(*) FROM ({base_query}) as count_query"
580
  count_result = await self.db.execute(text(count_query), params)
581
  total_count = count_result.scalar() or 0
582
 
@@ -675,7 +675,7 @@ class StockService:
675
  base_query += " AND " + " AND ".join(where_conditions)
676
 
677
  # Get total count
678
- count_query = f"SELECT COUNT(*) FROM ({base_query}) as count_query"
679
  count_result = await self.db.execute(text(count_query), params)
680
  total_count = count_result.scalar() or 0
681
 
 
476
  base_query += " AND " + " AND ".join(where_conditions)
477
 
478
  # Get total count
479
+ count_query = f"SELECT COUNT(1) FROM ({base_query}) as count_query"
480
  count_result = await self.db.execute(text(count_query), params)
481
  total_count = count_result.scalar() or 0
482
 
 
576
  base_query += " AND " + " AND ".join(where_conditions)
577
 
578
  # Get total count
579
+ count_query = f"SELECT COUNT(1) FROM ({base_query}) as count_query"
580
  count_result = await self.db.execute(text(count_query), params)
581
  total_count = count_result.scalar() or 0
582
 
 
675
  base_query += " AND " + " AND ".join(where_conditions)
676
 
677
  # Get total count
678
+ count_query = f"SELECT COUNT(1) FROM ({base_query}) as count_query"
679
  count_result = await self.db.execute(text(count_query), params)
680
  total_count = count_result.scalar() or 0
681
 
app/purchases/orders/services/service.py CHANGED
@@ -313,7 +313,7 @@ class OrdersService:
313
  base_query += " AND " + " AND ".join(where_conditions)
314
 
315
  # Get total count
316
- count_query = f"SELECT COUNT(*) FROM ({base_query}) AS count_subquery"
317
  count_result = await self.db.execute(text(count_query), params)
318
  total_count = count_result.scalar()
319
 
 
313
  base_query += " AND " + " AND ".join(where_conditions)
314
 
315
  # Get total count
316
+ count_query = f"SELECT COUNT(1) FROM ({base_query}) AS count_subquery"
317
  count_result = await self.db.execute(text(count_query), params)
318
  total_count = count_result.scalar()
319
 
app/trade_invoices/constants.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Trade Invoice Constants
3
+ Centralized constants for the Trade Invoice module
4
+ """
5
+ from enum import Enum
6
+
7
+
8
+ class InvoiceStatus(str, Enum):
9
+ """Invoice status enumeration following state machine"""
10
+ DRAFT = "draft"
11
+ SUBMITTED = "submitted"
12
+ ACCEPTED = "accepted"
13
+ DISPUTED = "disputed"
14
+
15
+
16
+ class InvoiceAction(str, Enum):
17
+ """Allowed invoice actions"""
18
+ SUBMIT = "submit"
19
+ ACCEPT = "accept"
20
+ DISPUTE = "dispute"
21
+
22
+
23
+ class POStatus(str, Enum):
24
+ """Purchase Order status constants"""
25
+ APPROVED = "approved"
26
+
27
+
28
+ class GRNStatus(str, Enum):
29
+ """GRN status constants"""
30
+ ACCEPTED = "accepted"
31
+ CONFIRMED = "confirmed"
32
+
33
+
34
+ class ValidationErrorCodes(str, Enum):
35
+ """Validation error codes for consistent error handling"""
36
+ PO_NOT_FOUND = "PO_NOT_FOUND"
37
+ PO_NOT_APPROVED = "PO_NOT_APPROVED"
38
+ GRN_NOT_FOUND = "GRN_NOT_FOUND"
39
+ GRN_NOT_ACCEPTED = "GRN_NOT_ACCEPTED"
40
+ PO_ITEM_NOT_FOUND = "PO_ITEM_NOT_FOUND"
41
+ GRN_ITEM_NOT_FOUND = "GRN_ITEM_NOT_FOUND"
42
+ PO_GRN_MISMATCH = "PO_GRN_MISMATCH"
43
+ QUANTITY_EXCEEDED = "QUANTITY_EXCEEDED"
44
+ TOTAL_QUANTITY_EXCEEDED = "TOTAL_QUANTITY_EXCEEDED"
45
+ INVOICE_NOT_FOUND = "INVOICE_NOT_FOUND"
46
+ INVALID_TRANSITION = "INVALID_TRANSITION"
47
+
48
+
49
+ # State machine transitions
50
+ VALID_TRANSITIONS = {
51
+ InvoiceStatus.DRAFT: [InvoiceAction.SUBMIT],
52
+ InvoiceStatus.SUBMITTED: [InvoiceAction.ACCEPT, InvoiceAction.DISPUTE],
53
+ InvoiceStatus.DISPUTED: [InvoiceAction.SUBMIT],
54
+ InvoiceStatus.ACCEPTED: [] # Terminal state
55
+ }
56
+
57
+ # Action to status mapping
58
+ ACTION_STATUS_MAP = {
59
+ InvoiceAction.SUBMIT: InvoiceStatus.SUBMITTED,
60
+ InvoiceAction.ACCEPT: InvoiceStatus.ACCEPTED,
61
+ InvoiceAction.DISPUTE: InvoiceStatus.DISPUTED
62
+ }
63
+
64
+ # Action success messages
65
+ ACTION_MESSAGES = {
66
+ InvoiceAction.SUBMIT: "Invoice submitted successfully",
67
+ InvoiceAction.ACCEPT: "Invoice accepted successfully",
68
+ InvoiceAction.DISPUTE: "Invoice disputed successfully"
69
+ }
70
+
71
+ # Default values
72
+ DEFAULT_CURRENCY = "INR"
73
+ DEFAULT_LIMIT = 10
74
+ MAX_LIMIT = 100
75
+ INVOICE_NUMBER_PREFIX = "INV"
76
+
77
+ # Valid GRN statuses for invoice creation
78
+ VALID_GRN_STATUSES = [GRNStatus.ACCEPTED, GRNStatus.CONFIRMED]
app/trade_invoices/controllers/router.py CHANGED
@@ -15,6 +15,7 @@ from app.trade_invoices.schemas.schema import (
15
  InvoiceCreate, InvoiceRead, InvoiceListFilter, InvoiceActionRequest,
16
  InvoiceCreateResponse, InvoiceActionResponse, InvoiceSummary
17
  )
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
@@ -121,15 +122,14 @@ async def update_invoice_status(
121
  }
122
  )
123
 
124
- action_messages = {
125
- "submit": "Invoice submitted successfully",
126
- "accept": "Invoice accepted successfully",
127
- "dispute": "Invoice disputed successfully"
128
- }
129
 
130
  return InvoiceActionResponse(
131
  success=True,
132
- message=action_messages.get(action_request.action.value, "Invoice updated successfully"),
133
  invoice_id=invoice.invoice_id,
134
  new_status=invoice.status
135
  )
@@ -244,7 +244,7 @@ async def list_invoices_get(
244
  buyer_id: str = None,
245
  status: str = None,
246
  skip: int = 0,
247
- limit: int = 10,
248
  db: AsyncSession = Depends(get_db),
249
  current_user: TokenUser = Depends(get_current_user)
250
  ):
 
15
  InvoiceCreate, InvoiceRead, InvoiceListFilter, InvoiceActionRequest,
16
  InvoiceCreateResponse, InvoiceActionResponse, InvoiceSummary
17
  )
18
+ from app.trade_invoices.constants import ACTION_MESSAGES, DEFAULT_LIMIT
19
 
20
  logger = logging.getLogger(__name__)
21
 
 
122
  }
123
  )
124
 
125
+ action_message = ACTION_MESSAGES.get(
126
+ action_request.action,
127
+ "Invoice updated successfully"
128
+ )
 
129
 
130
  return InvoiceActionResponse(
131
  success=True,
132
+ message=action_message,
133
  invoice_id=invoice.invoice_id,
134
  new_status=invoice.status
135
  )
 
244
  buyer_id: str = None,
245
  status: str = None,
246
  skip: int = 0,
247
+ limit: int = DEFAULT_LIMIT,
248
  db: AsyncSession = Depends(get_db),
249
  current_user: TokenUser = Depends(get_current_user)
250
  ):
app/trade_invoices/schemas/schema.py CHANGED
@@ -7,22 +7,10 @@ from typing import List, Optional, Dict, Any
7
  from uuid import UUID
8
  from decimal import Decimal
9
  from datetime import datetime, date
10
- from enum import Enum
11
 
12
-
13
- class InvoiceStatus(str, Enum):
14
- """Invoice status enumeration following state machine"""
15
- DRAFT = "draft"
16
- SUBMITTED = "submitted"
17
- ACCEPTED = "accepted"
18
- DISPUTED = "disputed"
19
-
20
-
21
- class InvoiceAction(str, Enum):
22
- """Allowed invoice actions"""
23
- SUBMIT = "submit"
24
- ACCEPT = "accept"
25
- DISPUTE = "dispute"
26
 
27
 
28
  class InvoiceItemCreate(BaseModel):
@@ -127,7 +115,7 @@ class InvoiceListFilter(BaseModel):
127
  """Invoice list request with filters and projection support"""
128
  filters: Optional[Dict[str, Any]] = Field(None, description="Filter criteria")
129
  skip: Optional[int] = Field(0, ge=0, description="Number of records to skip")
130
- limit: Optional[int] = Field(10, ge=1, le=100, description="Maximum records to return")
131
  projection_list: Optional[List[str]] = Field(
132
  None,
133
  description="List of fields to include in response"
 
7
  from uuid import UUID
8
  from decimal import Decimal
9
  from datetime import datetime, date
 
10
 
11
+ from app.trade_invoices.constants import (
12
+ InvoiceStatus, InvoiceAction, DEFAULT_LIMIT, MAX_LIMIT
13
+ )
 
 
 
 
 
 
 
 
 
 
 
14
 
15
 
16
  class InvoiceItemCreate(BaseModel):
 
115
  """Invoice list request with filters and projection support"""
116
  filters: Optional[Dict[str, Any]] = Field(None, description="Filter criteria")
117
  skip: Optional[int] = Field(0, ge=0, description="Number of records to skip")
118
+ limit: Optional[int] = Field(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT, description="Maximum records to return")
119
  projection_list: Optional[List[str]] = Field(
120
  None,
121
  description="List of fields to include in response"
app/trade_invoices/services/service.py CHANGED
@@ -14,7 +14,16 @@ from sqlalchemy.orm import selectinload
14
  from app.trade_invoices.models.model import ScmInvoice, ScmInvoiceItem, ScmInvoiceStatusLog
15
  from app.trade_invoices.schemas.schema import (
16
  InvoiceCreate, InvoiceRead, InvoiceListFilter, InvoiceActionRequest,
17
- InvoiceStatus, InvoiceAction, InvoiceValidationError
 
 
 
 
 
 
 
 
 
18
  )
19
  from app.purchases.orders.models.model import ScmPo, ScmPoItem
20
  from app.purchases.receipts.models.model import ScmGrn, ScmGrnItem
@@ -51,15 +60,15 @@ class TradeInvoiceService:
51
  errors.append(InvoiceValidationError(
52
  field="po_id",
53
  message="Purchase Order not found",
54
- code="PO_NOT_FOUND"
55
  ))
56
  return None, errors
57
 
58
- if po.status != "approved":
59
  errors.append(InvoiceValidationError(
60
  field="po_id",
61
- message=f"PO status is '{po.status}', must be 'approved'",
62
- code="PO_NOT_APPROVED"
63
  ))
64
  return None, errors
65
 
@@ -72,16 +81,16 @@ class TradeInvoiceService:
72
  errors.append(InvoiceValidationError(
73
  field="grn_ids",
74
  message="One or more GRNs not found",
75
- code="GRN_NOT_FOUND"
76
  ))
77
  return None, errors
78
 
79
  for grn in grns:
80
- if grn.status not in ["accepted", "confirmed"]:
81
  errors.append(InvoiceValidationError(
82
  field="grn_ids",
83
- message=f"GRN {grn.grn_no} status is '{grn.status}', must be 'accepted' or 'confirmed'",
84
- code="GRN_NOT_ACCEPTED"
85
  ))
86
 
87
  if errors:
@@ -138,11 +147,12 @@ class TradeInvoiceService:
138
  grn_item = grn_items[line.grn_item_id]
139
 
140
  # Calculate line totals (pricing locked from PO)
141
- line_net = line.invoice_qty * po_item.unit_price
142
- discount_amt = (line_net * po_item.discount_rate / 100) if hasattr(po_item, 'discount_rate') else Decimal(0)
143
- line_after_discount = line_net - discount_amt
144
- tax_amt = line_after_discount * po_item.tax_rate / 100
145
- line_total = line_after_discount + tax_amt
 
146
 
147
  invoice_item = ScmInvoiceItem(
148
  invoice_id=invoice.invoice_id,
@@ -152,15 +162,15 @@ class TradeInvoiceService:
152
  sku=po_item.sku,
153
  invoice_qty=line.invoice_qty,
154
  unit_price=po_item.unit_price,
155
- discount_amt=discount_amt,
156
  tax_rate=po_item.tax_rate,
157
- tax_amt=tax_amt,
158
- line_total=line_total
159
  )
160
 
161
  db.add(invoice_item)
162
- total_net += line_net
163
- total_tax += tax_amt
164
 
165
  # 7. Update invoice totals
166
  invoice.total_net_amt = total_net
@@ -196,7 +206,7 @@ class TradeInvoiceService:
196
  errors.append(InvoiceValidationError(
197
  field="po_item_id",
198
  message=f"PO item {line.po_item_id} not found",
199
- code="PO_ITEM_NOT_FOUND"
200
  ))
201
  return errors
202
 
@@ -205,7 +215,7 @@ class TradeInvoiceService:
205
  errors.append(InvoiceValidationError(
206
  field="grn_item_id",
207
  message=f"GRN item {line.grn_item_id} not found",
208
- code="GRN_ITEM_NOT_FOUND"
209
  ))
210
  return errors
211
 
@@ -217,7 +227,7 @@ class TradeInvoiceService:
217
  errors.append(InvoiceValidationError(
218
  field="grn_item_id",
219
  message="GRN item does not match PO item",
220
- code="PO_GRN_MISMATCH"
221
  ))
222
 
223
  # Validate invoice quantity ≀ GRN accepted quantity
@@ -225,7 +235,7 @@ class TradeInvoiceService:
225
  errors.append(InvoiceValidationError(
226
  field="invoice_qty",
227
  message=f"Invoice quantity {line.invoice_qty} exceeds GRN accepted quantity {grn_item.acc_qty}",
228
- code="QUANTITY_EXCEEDED"
229
  ))
230
 
231
  # Check for existing invoices to prevent over-billing
@@ -242,7 +252,7 @@ class TradeInvoiceService:
242
  errors.append(InvoiceValidationError(
243
  field="invoice_qty",
244
  message=f"Total invoice quantity would exceed GRN accepted quantity",
245
- code="TOTAL_QUANTITY_EXCEEDED"
246
  ))
247
 
248
  return errors
@@ -260,7 +270,7 @@ class TradeInvoiceService:
260
  result = await db.execute(query)
261
  count = result.scalar() or 0
262
 
263
- return f"INV-{current_year}-{count + 1:06d}"
264
 
265
  @staticmethod
266
  async def update_invoice_status(
@@ -289,7 +299,7 @@ class TradeInvoiceService:
289
  errors.append(InvoiceValidationError(
290
  field="invoice_id",
291
  message="Invoice not found",
292
- code="INVOICE_NOT_FOUND"
293
  ))
294
  return None, errors
295
 
@@ -297,28 +307,16 @@ class TradeInvoiceService:
297
  current_status = invoice.status
298
  action = action_request.action
299
 
300
- valid_transitions = {
301
- InvoiceStatus.DRAFT: [InvoiceAction.SUBMIT],
302
- InvoiceStatus.SUBMITTED: [InvoiceAction.ACCEPT, InvoiceAction.DISPUTE],
303
- InvoiceStatus.DISPUTED: [InvoiceAction.SUBMIT],
304
- InvoiceStatus.ACCEPTED: [] # Terminal state
305
- }
306
-
307
- if action not in valid_transitions.get(current_status, []):
308
  errors.append(InvoiceValidationError(
309
  field="action",
310
  message=f"Invalid transition: {current_status} β†’ {action}",
311
- code="INVALID_TRANSITION"
312
  ))
313
  return None, errors
314
 
315
  # Determine new status
316
- new_status_map = {
317
- InvoiceAction.SUBMIT: InvoiceStatus.SUBMITTED,
318
- InvoiceAction.ACCEPT: InvoiceStatus.ACCEPTED,
319
- InvoiceAction.DISPUTE: InvoiceStatus.DISPUTED
320
- }
321
- new_status = new_status_map[action]
322
 
323
  # Update invoice
324
  invoice.status = new_status
@@ -368,36 +366,51 @@ class TradeInvoiceService:
368
  List invoices with filters and projection support
369
  Following the mandatory projection list standard
370
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  # Build base query
372
  if projection_list:
373
  # Use raw SQL for projection to return dict
374
- select_fields = ", ".join([f"i.{field}" for field in projection_list])
375
- query = text(f"""
376
  SELECT {select_fields}
377
  FROM trans.scm_invoice i
378
  WHERE 1=1
379
- """)
380
 
381
  # Add filters
382
  params = {}
383
- if filters:
384
- if "supplier_id" in filters:
385
- query = text(str(query) + " AND i.supplier_id = :supplier_id")
386
- params["supplier_id"] = filters["supplier_id"]
387
- if "buyer_id" in filters:
388
- query = text(str(query) + " AND i.buyer_id = :buyer_id")
389
- params["buyer_id"] = filters["buyer_id"]
390
- if "status" in filters:
391
- query = text(str(query) + " AND i.status = :status")
392
- params["status"] = filters["status"]
393
- if "po_id" in filters:
394
- query = text(str(query) + " AND i.po_id = :po_id")
395
- params["po_id"] = filters["po_id"]
396
 
397
  # Add pagination
398
- query = text(str(query) + " ORDER BY i.created_at DESC OFFSET :skip LIMIT :limit")
399
  params.update({"skip": skip, "limit": limit})
400
 
 
401
  result = await db.execute(query, params)
402
  return [dict(row._mapping) for row in result.fetchall()]
403
 
@@ -406,15 +419,15 @@ class TradeInvoiceService:
406
  query = select(ScmInvoice)
407
 
408
  # Add filters
409
- if filters:
410
- if "supplier_id" in filters:
411
- query = query.where(ScmInvoice.supplier_id == filters["supplier_id"])
412
- if "buyer_id" in filters:
413
- query = query.where(ScmInvoice.buyer_id == filters["buyer_id"])
414
- if "status" in filters:
415
- query = query.where(ScmInvoice.status == filters["status"])
416
- if "po_id" in filters:
417
- query = query.where(ScmInvoice.po_id == filters["po_id"])
418
 
419
  # Add pagination and ordering
420
  query = query.order_by(ScmInvoice.created_at.desc()).offset(skip).limit(limit)
@@ -423,26 +436,7 @@ class TradeInvoiceService:
423
  invoices = result.scalars().all()
424
 
425
  # Convert to dict for consistent return type
426
- return [
427
- {
428
- "invoice_id": str(invoice.invoice_id),
429
- "invoice_no": invoice.invoice_no,
430
- "supplier_id": invoice.supplier_id,
431
- "buyer_id": invoice.buyer_id,
432
- "po_id": str(invoice.po_id),
433
- "currency": invoice.currency,
434
- "invoice_date": invoice.invoice_date.isoformat(),
435
- "due_date": invoice.due_date.isoformat() if invoice.due_date else None,
436
- "total_tax_amt": float(invoice.total_tax_amt) if invoice.total_tax_amt else None,
437
- "total_net_amt": float(invoice.total_net_amt) if invoice.total_net_amt else None,
438
- "total_gross_amt": float(invoice.total_gross_amt) if invoice.total_gross_amt else None,
439
- "status": invoice.status,
440
- "created_by": invoice.created_by,
441
- "created_at": invoice.created_at.isoformat(),
442
- "updated_at": invoice.updated_at.isoformat()
443
- }
444
- for invoice in invoices
445
- ]
446
 
447
  @staticmethod
448
  async def get_invoice_summary(
 
14
  from app.trade_invoices.models.model import ScmInvoice, ScmInvoiceItem, ScmInvoiceStatusLog
15
  from app.trade_invoices.schemas.schema import (
16
  InvoiceCreate, InvoiceRead, InvoiceListFilter, InvoiceActionRequest,
17
+ InvoiceValidationError
18
+ )
19
+ from app.trade_invoices.constants import (
20
+ InvoiceStatus, InvoiceAction, POStatus, VALID_GRN_STATUSES,
21
+ VALID_TRANSITIONS, ACTION_STATUS_MAP, ValidationErrorCodes
22
+ )
23
+ from app.trade_invoices.utils import (
24
+ clean_filters, generate_invoice_number, calculate_line_totals,
25
+ format_invoice_for_response, build_projection_query,
26
+ validate_projection_fields, ALLOWED_INVOICE_PROJECTION_FIELDS
27
  )
28
  from app.purchases.orders.models.model import ScmPo, ScmPoItem
29
  from app.purchases.receipts.models.model import ScmGrn, ScmGrnItem
 
60
  errors.append(InvoiceValidationError(
61
  field="po_id",
62
  message="Purchase Order not found",
63
+ code=ValidationErrorCodes.PO_NOT_FOUND
64
  ))
65
  return None, errors
66
 
67
+ if po.status != POStatus.APPROVED:
68
  errors.append(InvoiceValidationError(
69
  field="po_id",
70
+ message=f"PO status is '{po.status}', must be '{POStatus.APPROVED}'",
71
+ code=ValidationErrorCodes.PO_NOT_APPROVED
72
  ))
73
  return None, errors
74
 
 
81
  errors.append(InvoiceValidationError(
82
  field="grn_ids",
83
  message="One or more GRNs not found",
84
+ code=ValidationErrorCodes.GRN_NOT_FOUND
85
  ))
86
  return None, errors
87
 
88
  for grn in grns:
89
+ if grn.status not in VALID_GRN_STATUSES:
90
  errors.append(InvoiceValidationError(
91
  field="grn_ids",
92
+ message=f"GRN {grn.grn_no} status is '{grn.status}', must be one of {VALID_GRN_STATUSES}",
93
+ code=ValidationErrorCodes.GRN_NOT_ACCEPTED
94
  ))
95
 
96
  if errors:
 
147
  grn_item = grn_items[line.grn_item_id]
148
 
149
  # Calculate line totals (pricing locked from PO)
150
+ totals = calculate_line_totals(
151
+ quantity=line.invoice_qty,
152
+ unit_price=po_item.unit_price,
153
+ discount_rate=getattr(po_item, 'discount_rate', None),
154
+ tax_rate=po_item.tax_rate
155
+ )
156
 
157
  invoice_item = ScmInvoiceItem(
158
  invoice_id=invoice.invoice_id,
 
162
  sku=po_item.sku,
163
  invoice_qty=line.invoice_qty,
164
  unit_price=po_item.unit_price,
165
+ discount_amt=totals["discount_amt"],
166
  tax_rate=po_item.tax_rate,
167
+ tax_amt=totals["tax_amt"],
168
+ line_total=totals["line_total"]
169
  )
170
 
171
  db.add(invoice_item)
172
+ total_net += totals["line_net"]
173
+ total_tax += totals["tax_amt"]
174
 
175
  # 7. Update invoice totals
176
  invoice.total_net_amt = total_net
 
206
  errors.append(InvoiceValidationError(
207
  field="po_item_id",
208
  message=f"PO item {line.po_item_id} not found",
209
+ code=ValidationErrorCodes.PO_ITEM_NOT_FOUND
210
  ))
211
  return errors
212
 
 
215
  errors.append(InvoiceValidationError(
216
  field="grn_item_id",
217
  message=f"GRN item {line.grn_item_id} not found",
218
+ code=ValidationErrorCodes.GRN_ITEM_NOT_FOUND
219
  ))
220
  return errors
221
 
 
227
  errors.append(InvoiceValidationError(
228
  field="grn_item_id",
229
  message="GRN item does not match PO item",
230
+ code=ValidationErrorCodes.PO_GRN_MISMATCH
231
  ))
232
 
233
  # Validate invoice quantity ≀ GRN accepted quantity
 
235
  errors.append(InvoiceValidationError(
236
  field="invoice_qty",
237
  message=f"Invoice quantity {line.invoice_qty} exceeds GRN accepted quantity {grn_item.acc_qty}",
238
+ code=ValidationErrorCodes.QUANTITY_EXCEEDED
239
  ))
240
 
241
  # Check for existing invoices to prevent over-billing
 
252
  errors.append(InvoiceValidationError(
253
  field="invoice_qty",
254
  message=f"Total invoice quantity would exceed GRN accepted quantity",
255
+ code=ValidationErrorCodes.TOTAL_QUANTITY_EXCEEDED
256
  ))
257
 
258
  return errors
 
270
  result = await db.execute(query)
271
  count = result.scalar() or 0
272
 
273
+ return generate_invoice_number(current_year, count + 1)
274
 
275
  @staticmethod
276
  async def update_invoice_status(
 
299
  errors.append(InvoiceValidationError(
300
  field="invoice_id",
301
  message="Invoice not found",
302
+ code=ValidationErrorCodes.INVOICE_NOT_FOUND
303
  ))
304
  return None, errors
305
 
 
307
  current_status = invoice.status
308
  action = action_request.action
309
 
310
+ if action not in VALID_TRANSITIONS.get(current_status, []):
 
 
 
 
 
 
 
311
  errors.append(InvoiceValidationError(
312
  field="action",
313
  message=f"Invalid transition: {current_status} β†’ {action}",
314
+ code=ValidationErrorCodes.INVALID_TRANSITION
315
  ))
316
  return None, errors
317
 
318
  # Determine new status
319
+ new_status = ACTION_STATUS_MAP[action]
 
 
 
 
 
320
 
321
  # Update invoice
322
  invoice.status = new_status
 
366
  List invoices with filters and projection support
367
  Following the mandatory projection list standard
368
  """
369
+ # Clean filters - remove None, empty strings, and empty lists
370
+ clean_filter_dict = clean_filters(filters)
371
+
372
+ # Validate projection fields if provided
373
+ if projection_list:
374
+ invalid_fields = validate_projection_fields(
375
+ projection_list,
376
+ ALLOWED_INVOICE_PROJECTION_FIELDS
377
+ )
378
+ if invalid_fields:
379
+ logger.warning(f"Invalid projection fields: {invalid_fields}")
380
+ # Filter out invalid fields instead of failing
381
+ projection_list = [f for f in projection_list if f in ALLOWED_INVOICE_PROJECTION_FIELDS]
382
+
383
  # Build base query
384
  if projection_list:
385
  # Use raw SQL for projection to return dict
386
+ select_fields = build_projection_query(projection_list, "i")
387
+ query_str = f"""
388
  SELECT {select_fields}
389
  FROM trans.scm_invoice i
390
  WHERE 1=1
391
+ """
392
 
393
  # Add filters
394
  params = {}
395
+ if clean_filter_dict:
396
+ if "supplier_id" in clean_filter_dict:
397
+ query_str += " AND i.supplier_id = :supplier_id"
398
+ params["supplier_id"] = clean_filter_dict["supplier_id"]
399
+ if "buyer_id" in clean_filter_dict:
400
+ query_str += " AND i.buyer_id = :buyer_id"
401
+ params["buyer_id"] = clean_filter_dict["buyer_id"]
402
+ if "status" in clean_filter_dict:
403
+ query_str += " AND i.status = :status"
404
+ params["status"] = clean_filter_dict["status"]
405
+ if "po_id" in clean_filter_dict:
406
+ query_str += " AND i.po_id = :po_id"
407
+ params["po_id"] = clean_filter_dict["po_id"]
408
 
409
  # Add pagination
410
+ query_str += " ORDER BY i.created_at DESC OFFSET :skip LIMIT :limit"
411
  params.update({"skip": skip, "limit": limit})
412
 
413
+ query = text(query_str)
414
  result = await db.execute(query, params)
415
  return [dict(row._mapping) for row in result.fetchall()]
416
 
 
419
  query = select(ScmInvoice)
420
 
421
  # Add filters
422
+ if clean_filter_dict:
423
+ if "supplier_id" in clean_filter_dict:
424
+ query = query.where(ScmInvoice.supplier_id == clean_filter_dict["supplier_id"])
425
+ if "buyer_id" in clean_filter_dict:
426
+ query = query.where(ScmInvoice.buyer_id == clean_filter_dict["buyer_id"])
427
+ if "status" in clean_filter_dict:
428
+ query = query.where(ScmInvoice.status == clean_filter_dict["status"])
429
+ if "po_id" in clean_filter_dict:
430
+ query = query.where(ScmInvoice.po_id == clean_filter_dict["po_id"])
431
 
432
  # Add pagination and ordering
433
  query = query.order_by(ScmInvoice.created_at.desc()).offset(skip).limit(limit)
 
436
  invoices = result.scalars().all()
437
 
438
  # Convert to dict for consistent return type
439
+ return [format_invoice_for_response(invoice) for invoice in invoices]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
 
441
  @staticmethod
442
  async def get_invoice_summary(
app/trade_invoices/tests/test_cleanup_verification.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Trade Invoice Cleanup Verification Tests
3
+ Tests to verify the cleanup improvements work correctly
4
+ """
5
+ import pytest
6
+ from decimal import Decimal
7
+ from uuid import uuid4
8
+
9
+ from app.trade_invoices.constants import (
10
+ InvoiceStatus, InvoiceAction, POStatus, GRNStatus,
11
+ ValidationErrorCodes, VALID_TRANSITIONS, ACTION_STATUS_MAP,
12
+ ACTION_MESSAGES, DEFAULT_CURRENCY, INVOICE_NUMBER_PREFIX
13
+ )
14
+ from app.trade_invoices.utils import (
15
+ clean_filters, generate_invoice_number, calculate_line_totals,
16
+ format_invoice_for_response, build_projection_query,
17
+ validate_projection_fields, ALLOWED_INVOICE_PROJECTION_FIELDS
18
+ )
19
+
20
+
21
+ class TestConstants:
22
+ """Test constants are properly defined"""
23
+
24
+ def test_invoice_status_enum(self):
25
+ """Test invoice status enum values"""
26
+ assert InvoiceStatus.DRAFT == "draft"
27
+ assert InvoiceStatus.SUBMITTED == "submitted"
28
+ assert InvoiceStatus.ACCEPTED == "accepted"
29
+ assert InvoiceStatus.DISPUTED == "disputed"
30
+
31
+ def test_invoice_action_enum(self):
32
+ """Test invoice action enum values"""
33
+ assert InvoiceAction.SUBMIT == "submit"
34
+ assert InvoiceAction.ACCEPT == "accept"
35
+ assert InvoiceAction.DISPUTE == "dispute"
36
+
37
+ def test_valid_transitions(self):
38
+ """Test state machine transitions"""
39
+ assert VALID_TRANSITIONS[InvoiceStatus.DRAFT] == [InvoiceAction.SUBMIT]
40
+ assert InvoiceAction.ACCEPT in VALID_TRANSITIONS[InvoiceStatus.SUBMITTED]
41
+ assert InvoiceAction.DISPUTE in VALID_TRANSITIONS[InvoiceStatus.SUBMITTED]
42
+ assert VALID_TRANSITIONS[InvoiceStatus.DISPUTED] == [InvoiceAction.SUBMIT]
43
+ assert VALID_TRANSITIONS[InvoiceStatus.ACCEPTED] == []
44
+
45
+ def test_action_status_mapping(self):
46
+ """Test action to status mapping"""
47
+ assert ACTION_STATUS_MAP[InvoiceAction.SUBMIT] == InvoiceStatus.SUBMITTED
48
+ assert ACTION_STATUS_MAP[InvoiceAction.ACCEPT] == InvoiceStatus.ACCEPTED
49
+ assert ACTION_STATUS_MAP[InvoiceAction.DISPUTE] == InvoiceStatus.DISPUTED
50
+
51
+ def test_action_messages(self):
52
+ """Test action success messages"""
53
+ assert "submitted successfully" in ACTION_MESSAGES[InvoiceAction.SUBMIT]
54
+ assert "accepted successfully" in ACTION_MESSAGES[InvoiceAction.ACCEPT]
55
+ assert "disputed successfully" in ACTION_MESSAGES[InvoiceAction.DISPUTE]
56
+
57
+
58
+ class TestUtils:
59
+ """Test utility functions"""
60
+
61
+ def test_clean_filters(self):
62
+ """Test filter cleaning function"""
63
+ # Test with None values
64
+ filters = {"supplier_id": "SUPP001", "buyer_id": None, "status": ""}
65
+ cleaned = clean_filters(filters)
66
+ assert cleaned == {"supplier_id": "SUPP001"}
67
+
68
+ # Test with empty list
69
+ filters = {"supplier_id": "SUPP001", "tags": []}
70
+ cleaned = clean_filters(filters)
71
+ assert cleaned == {"supplier_id": "SUPP001"}
72
+
73
+ # Test with None input
74
+ cleaned = clean_filters(None)
75
+ assert cleaned == {}
76
+
77
+ def test_generate_invoice_number(self):
78
+ """Test invoice number generation"""
79
+ invoice_no = generate_invoice_number(2024, 1)
80
+ assert invoice_no == f"{INVOICE_NUMBER_PREFIX}-2024-000001"
81
+
82
+ invoice_no = generate_invoice_number(2024, 999)
83
+ assert invoice_no == f"{INVOICE_NUMBER_PREFIX}-2024-000999"
84
+
85
+ def test_calculate_line_totals(self):
86
+ """Test line total calculations"""
87
+ # Test without discount and tax
88
+ totals = calculate_line_totals(
89
+ quantity=Decimal("10"),
90
+ unit_price=Decimal("100")
91
+ )
92
+ assert totals["line_net"] == Decimal("1000")
93
+ assert totals["discount_amt"] == Decimal("0")
94
+ assert totals["tax_amt"] == Decimal("0")
95
+ assert totals["line_total"] == Decimal("1000")
96
+
97
+ # Test with discount and tax
98
+ totals = calculate_line_totals(
99
+ quantity=Decimal("10"),
100
+ unit_price=Decimal("100"),
101
+ discount_rate=Decimal("10"), # 10%
102
+ tax_rate=Decimal("18") # 18%
103
+ )
104
+ assert totals["line_net"] == Decimal("1000")
105
+ assert totals["discount_amt"] == Decimal("100") # 10% of 1000
106
+ assert totals["line_after_discount"] == Decimal("900")
107
+ assert totals["tax_amt"] == Decimal("162") # 18% of 900
108
+ assert totals["line_total"] == Decimal("1062") # 900 + 162
109
+
110
+ def test_build_projection_query(self):
111
+ """Test projection query building"""
112
+ fields = ["invoice_id", "invoice_no", "status"]
113
+ query = build_projection_query(fields, "i")
114
+ expected = "i.invoice_id, i.invoice_no, i.status"
115
+ assert query == expected
116
+
117
+ def test_validate_projection_fields(self):
118
+ """Test projection field validation"""
119
+ # Valid fields
120
+ valid_fields = ["invoice_id", "invoice_no", "status"]
121
+ invalid = validate_projection_fields(valid_fields, ALLOWED_INVOICE_PROJECTION_FIELDS)
122
+ assert invalid == []
123
+
124
+ # Invalid fields
125
+ mixed_fields = ["invoice_id", "invalid_field", "status"]
126
+ invalid = validate_projection_fields(mixed_fields, ALLOWED_INVOICE_PROJECTION_FIELDS)
127
+ assert "invalid_field" in invalid
128
+ assert len(invalid) == 1
129
+
130
+ # Empty list
131
+ invalid = validate_projection_fields([], ALLOWED_INVOICE_PROJECTION_FIELDS)
132
+ assert invalid == []
133
+
134
+ # None input
135
+ invalid = validate_projection_fields(None, ALLOWED_INVOICE_PROJECTION_FIELDS)
136
+ assert invalid == []
137
+
138
+
139
+ class TestValidationErrorCodes:
140
+ """Test validation error codes are properly defined"""
141
+
142
+ def test_error_codes_exist(self):
143
+ """Test all expected error codes exist"""
144
+ expected_codes = [
145
+ "PO_NOT_FOUND", "PO_NOT_APPROVED", "GRN_NOT_FOUND",
146
+ "GRN_NOT_ACCEPTED", "PO_ITEM_NOT_FOUND", "GRN_ITEM_NOT_FOUND",
147
+ "PO_GRN_MISMATCH", "QUANTITY_EXCEEDED", "TOTAL_QUANTITY_EXCEEDED",
148
+ "INVOICE_NOT_FOUND", "INVALID_TRANSITION"
149
+ ]
150
+
151
+ for code in expected_codes:
152
+ assert hasattr(ValidationErrorCodes, code)
153
+ assert getattr(ValidationErrorCodes, code) == code
154
+
155
+
156
+ class TestProjectionListCompliance:
157
+ """Test projection list compliance is maintained"""
158
+
159
+ def test_allowed_projection_fields(self):
160
+ """Test allowed projection fields are comprehensive"""
161
+ expected_fields = [
162
+ "invoice_id", "invoice_no", "supplier_id", "buyer_id", "po_id",
163
+ "currency", "invoice_date", "due_date", "total_tax_amt",
164
+ "total_net_amt", "total_gross_amt", "status", "created_by",
165
+ "created_at", "updated_at"
166
+ ]
167
+
168
+ for field in expected_fields:
169
+ assert field in ALLOWED_INVOICE_PROJECTION_FIELDS
170
+
171
+ def test_projection_field_validation_security(self):
172
+ """Test projection validation prevents SQL injection"""
173
+ malicious_fields = [
174
+ "invoice_id; DROP TABLE scm_invoice;",
175
+ "invoice_id UNION SELECT * FROM users",
176
+ "invoice_id' OR '1'='1"
177
+ ]
178
+
179
+ for field in malicious_fields:
180
+ invalid = validate_projection_fields([field], ALLOWED_INVOICE_PROJECTION_FIELDS)
181
+ assert field in invalid # Should be rejected
182
+
183
+
184
+ class MockInvoice:
185
+ """Mock invoice object for testing"""
186
+ def __init__(self):
187
+ self.invoice_id = uuid4()
188
+ self.invoice_no = "INV-2024-000001"
189
+ self.supplier_id = "SUPP001"
190
+ self.buyer_id = "BUYER001"
191
+ self.po_id = uuid4()
192
+ self.currency = "INR"
193
+ self.invoice_date = "2024-01-01T00:00:00"
194
+ self.due_date = None
195
+ self.total_tax_amt = Decimal("180")
196
+ self.total_net_amt = Decimal("1000")
197
+ self.total_gross_amt = Decimal("1180")
198
+ self.status = "draft"
199
+ self.created_by = "user001"
200
+ self.created_at = "2024-01-01T00:00:00"
201
+ self.updated_at = "2024-01-01T00:00:00"
202
+
203
+
204
+ class TestFormatting:
205
+ """Test response formatting functions"""
206
+
207
+ def test_format_invoice_for_response(self):
208
+ """Test invoice formatting for API response"""
209
+ mock_invoice = MockInvoice()
210
+ formatted = format_invoice_for_response(mock_invoice)
211
+
212
+ # Check all required fields are present
213
+ required_fields = [
214
+ "invoice_id", "invoice_no", "supplier_id", "buyer_id", "po_id",
215
+ "currency", "invoice_date", "due_date", "total_tax_amt",
216
+ "total_net_amt", "total_gross_amt", "status", "created_by",
217
+ "created_at", "updated_at"
218
+ ]
219
+
220
+ for field in required_fields:
221
+ assert field in formatted
222
+
223
+ # Check UUID fields are converted to strings
224
+ assert isinstance(formatted["invoice_id"], str)
225
+ assert isinstance(formatted["po_id"], str)
226
+
227
+ # Check decimal fields are converted to float
228
+ assert isinstance(formatted["total_tax_amt"], float)
229
+ assert formatted["total_tax_amt"] == 180.0
230
+
231
+
232
+ if __name__ == "__main__":
233
+ # Run basic tests
234
+ test_constants = TestConstants()
235
+ test_constants.test_invoice_status_enum()
236
+ test_constants.test_valid_transitions()
237
+
238
+ test_utils = TestUtils()
239
+ test_utils.test_clean_filters()
240
+ test_utils.test_calculate_line_totals()
241
+
242
+ print("βœ… All cleanup verification tests passed!")
app/trade_invoices/utils.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Trade Invoice Utility Functions
3
+ Helper functions for invoice processing and validation
4
+ """
5
+ from typing import Dict, Any, List, Optional
6
+ from decimal import Decimal
7
+ from datetime import datetime
8
+ from uuid import UUID
9
+
10
+ from app.trade_invoices.constants import DEFAULT_CURRENCY, INVOICE_NUMBER_PREFIX
11
+
12
+
13
+ def clean_filters(filters: Optional[Dict[str, Any]]) -> Dict[str, Any]:
14
+ """
15
+ Clean filters by removing None, empty strings, and empty lists
16
+
17
+ Args:
18
+ filters: Raw filter dictionary
19
+
20
+ Returns:
21
+ Cleaned filter dictionary
22
+ """
23
+ if not filters:
24
+ return {}
25
+
26
+ clean_filters = {}
27
+ for key, value in filters.items():
28
+ if value is not None and value != "" and value != []:
29
+ clean_filters[key] = value
30
+
31
+ return clean_filters
32
+
33
+
34
+ def generate_invoice_number(year: int, sequence: int) -> str:
35
+ """
36
+ Generate invoice number with consistent format
37
+
38
+ Args:
39
+ year: Invoice year
40
+ sequence: Sequence number for the year
41
+
42
+ Returns:
43
+ Formatted invoice number
44
+ """
45
+ return f"{INVOICE_NUMBER_PREFIX}-{year}-{sequence:06d}"
46
+
47
+
48
+ def calculate_line_totals(
49
+ quantity: Decimal,
50
+ unit_price: Decimal,
51
+ discount_rate: Optional[Decimal] = None,
52
+ tax_rate: Optional[Decimal] = None
53
+ ) -> Dict[str, Decimal]:
54
+ """
55
+ Calculate line item totals with discount and tax
56
+
57
+ Args:
58
+ quantity: Invoice quantity
59
+ unit_price: Unit price from PO
60
+ discount_rate: Discount rate percentage (optional)
61
+ tax_rate: Tax rate percentage (optional)
62
+
63
+ Returns:
64
+ Dictionary with calculated amounts
65
+ """
66
+ line_net = quantity * unit_price
67
+ discount_amt = Decimal(0)
68
+
69
+ if discount_rate:
70
+ discount_amt = line_net * discount_rate / 100
71
+
72
+ line_after_discount = line_net - discount_amt
73
+ tax_amt = Decimal(0)
74
+
75
+ if tax_rate:
76
+ tax_amt = line_after_discount * tax_rate / 100
77
+
78
+ line_total = line_after_discount + tax_amt
79
+
80
+ return {
81
+ "line_net": line_net,
82
+ "discount_amt": discount_amt,
83
+ "line_after_discount": line_after_discount,
84
+ "tax_amt": tax_amt,
85
+ "line_total": line_total
86
+ }
87
+
88
+
89
+ def format_invoice_for_response(invoice: Any) -> Dict[str, Any]:
90
+ """
91
+ Format invoice model for API response
92
+
93
+ Args:
94
+ invoice: ScmInvoice model instance
95
+
96
+ Returns:
97
+ Formatted invoice dictionary
98
+ """
99
+ return {
100
+ "invoice_id": str(invoice.invoice_id),
101
+ "invoice_no": invoice.invoice_no,
102
+ "supplier_id": invoice.supplier_id,
103
+ "buyer_id": invoice.buyer_id,
104
+ "po_id": str(invoice.po_id),
105
+ "currency": invoice.currency,
106
+ "invoice_date": invoice.invoice_date.isoformat(),
107
+ "due_date": invoice.due_date.isoformat() if invoice.due_date else None,
108
+ "total_tax_amt": float(invoice.total_tax_amt) if invoice.total_tax_amt else None,
109
+ "total_net_amt": float(invoice.total_net_amt) if invoice.total_net_amt else None,
110
+ "total_gross_amt": float(invoice.total_gross_amt) if invoice.total_gross_amt else None,
111
+ "status": invoice.status,
112
+ "created_by": invoice.created_by,
113
+ "created_at": invoice.created_at.isoformat(),
114
+ "updated_at": invoice.updated_at.isoformat()
115
+ }
116
+
117
+
118
+ def build_projection_query(
119
+ projection_list: List[str],
120
+ base_table_alias: str = "i"
121
+ ) -> str:
122
+ """
123
+ Build SQL projection fields for raw queries
124
+
125
+ Args:
126
+ projection_list: List of fields to project
127
+ base_table_alias: Table alias for the query
128
+
129
+ Returns:
130
+ Comma-separated projection fields
131
+ """
132
+ return ", ".join([f"{base_table_alias}.{field}" for field in projection_list])
133
+
134
+
135
+ def validate_projection_fields(
136
+ projection_list: List[str],
137
+ allowed_fields: List[str]
138
+ ) -> List[str]:
139
+ """
140
+ Validate projection fields against allowed fields
141
+
142
+ Args:
143
+ projection_list: Requested projection fields
144
+ allowed_fields: List of allowed field names
145
+
146
+ Returns:
147
+ List of invalid field names
148
+ """
149
+ if not projection_list:
150
+ return []
151
+
152
+ invalid_fields = []
153
+ for field in projection_list:
154
+ if field not in allowed_fields:
155
+ invalid_fields.append(field)
156
+
157
+ return invalid_fields
158
+
159
+
160
+ # Allowed projection fields for invoice list
161
+ ALLOWED_INVOICE_PROJECTION_FIELDS = [
162
+ "invoice_id", "invoice_no", "supplier_id", "buyer_id", "po_id",
163
+ "currency", "invoice_date", "due_date", "total_tax_amt",
164
+ "total_net_amt", "total_gross_amt", "status", "created_by",
165
+ "created_at", "updated_at"
166
+ ]
app/trade_returns/services/service.py CHANGED
@@ -598,36 +598,43 @@ class TradeReturnService:
598
  Following API standards for projection list
599
  """
600
  try:
 
 
 
 
 
 
 
601
  # Build base query
602
  query = select(TradeReturn)
603
 
604
  # Apply filters
605
  conditions = []
606
 
607
- if filters.get("return_type"):
608
- conditions.append(TradeReturn.return_type == ReturnType(filters["return_type"]))
609
 
610
- if filters.get("status"):
611
- conditions.append(TradeReturn.status == ReturnStatus(filters["status"]))
612
 
613
- if filters.get("buyer_id"):
614
- conditions.append(TradeReturn.buyer_id == filters["buyer_id"])
615
 
616
- if filters.get("supplier_id"):
617
- conditions.append(TradeReturn.supplier_id == filters["supplier_id"])
618
 
619
- if filters.get("invoice_id"):
620
- conditions.append(TradeReturn.invoice_id == UUID(filters["invoice_id"]))
621
 
622
- if filters.get("reason_code"):
623
- conditions.append(TradeReturn.reason_code == ReturnReasonCode(filters["reason_code"]))
624
 
625
  # Date range filters
626
- if filters.get("date_from"):
627
- conditions.append(TradeReturn.created_at >= filters["date_from"])
628
 
629
- if filters.get("date_to"):
630
- conditions.append(TradeReturn.created_at <= filters["date_to"])
631
 
632
  if conditions:
633
  query = query.where(and_(*conditions))
@@ -678,7 +685,7 @@ class TradeReturnService:
678
 
679
  query = text(f"""
680
  SELECT
681
- COUNT(*) as total_returns,
682
  COUNT(CASE WHEN status = 'DRAFT' THEN 1 END) as draft_count,
683
  COUNT(CASE WHEN status = 'DISPATCHED' THEN 1 END) as dispatched_count,
684
  COUNT(CASE WHEN status = 'RECEIVED' THEN 1 END) as received_count,
 
598
  Following API standards for projection list
599
  """
600
  try:
601
+ # Clean filters - remove None, empty strings, and empty lists
602
+ clean_filters = {}
603
+ if filters:
604
+ for key, value in filters.items():
605
+ if value is not None and value != "" and value != []:
606
+ clean_filters[key] = value
607
+
608
  # Build base query
609
  query = select(TradeReturn)
610
 
611
  # Apply filters
612
  conditions = []
613
 
614
+ if clean_filters.get("return_type"):
615
+ conditions.append(TradeReturn.return_type == ReturnType(clean_filters["return_type"]))
616
 
617
+ if clean_filters.get("status"):
618
+ conditions.append(TradeReturn.status == ReturnStatus(clean_filters["status"]))
619
 
620
+ if clean_filters.get("buyer_id"):
621
+ conditions.append(TradeReturn.buyer_id == clean_filters["buyer_id"])
622
 
623
+ if clean_filters.get("supplier_id"):
624
+ conditions.append(TradeReturn.supplier_id == clean_filters["supplier_id"])
625
 
626
+ if clean_filters.get("invoice_id"):
627
+ conditions.append(TradeReturn.invoice_id == UUID(clean_filters["invoice_id"]))
628
 
629
+ if clean_filters.get("reason_code"):
630
+ conditions.append(TradeReturn.reason_code == ReturnReasonCode(clean_filters["reason_code"]))
631
 
632
  # Date range filters
633
+ if clean_filters.get("date_from"):
634
+ conditions.append(TradeReturn.created_at >= clean_filters["date_from"])
635
 
636
+ if clean_filters.get("date_to"):
637
+ conditions.append(TradeReturn.created_at <= clean_filters["date_to"])
638
 
639
  if conditions:
640
  query = query.where(and_(*conditions))
 
685
 
686
  query = text(f"""
687
  SELECT
688
+ COUNT(1) as total_returns,
689
  COUNT(CASE WHEN status = 'DRAFT' THEN 1 END) as draft_count,
690
  COUNT(CASE WHEN status = 'DISPATCHED' THEN 1 END) as dispatched_count,
691
  COUNT(CASE WHEN status = 'RECEIVED' THEN 1 END) as received_count,
app/trade_sales/services/service.py CHANGED
@@ -51,6 +51,7 @@ class TradeSalesService:
51
 
52
  async def list_client_orders(
53
  self,
 
54
  filters: ClientOrderFilters,
55
  skip: int = 0,
56
  limit: int = 100,
@@ -59,106 +60,103 @@ class TradeSalesService:
59
  """
60
  List client orders (distributor POs from cnf perspective) with projection support.
61
 
 
 
 
 
 
 
 
62
  Returns:
63
  Tuple of (orders_list, total_count)
64
  """
65
  try:
66
- # Build base query for client orders (POs where supplier is cnf)
67
  base_query = """
68
- SELECT
69
- po.po_id as order_id,
70
- po.po_no as order_no,
71
- po.po_date as order_date,
72
- po.exp_delivery_dt as expected_delivery_date,
73
- po.buyer_id as client_id,
74
- mr.merchant_name AS client_name,
75
- po.supplier_id,
76
- po.total_amt,
77
- po.status as po_status,
78
- po.remarks,
79
- COALESCE(SUM(poi.ord_qty), 0) as total_qty,
80
- COALESCE(SUM(tsi.shipped_qty), 0) as shipped_qty,
81
- COALESCE(SUM(poi.ord_qty) - SUM(tsi.shipped_qty), SUM(poi.ord_qty)) as pending_qty,
82
- CASE
83
- WHEN COALESCE(SUM(tsi.shipped_qty), 0) = 0 THEN 'pending'
84
- WHEN COALESCE(SUM(tsi.shipped_qty), 0) < SUM(poi.ord_qty) THEN 'partial'
85
- WHEN COALESCE(SUM(tsi.shipped_qty), 0) >= SUM(poi.ord_qty) THEN 'completed'
86
- ELSE 'pending'
87
- END AS fulfillment_status
88
- FROM trans.scm_po po
89
- LEFT JOIN trans.scm_po_item poi ON po.po_id = poi.po_id
90
- LEFT JOIN trans.scm_trade_shipment_item tsi ON poi.po_item_id = tsi.po_item_id
91
- LEFT JOIN trans.merchants_ref mr ON po.buyer_id = mr.merchant_id
92
- WHERE po.supplier_type IN ('cnf', 'ncnf', 'ncnf', 'cnf')
93
- AND po.buyer_type IN ('distributor', 'distributor', 'cnf', 'cnf')
 
 
 
94
  """
95
 
96
- # Add filters
 
 
 
97
  conditions = []
98
- params = {}
99
 
100
  if filters.client_id:
101
- conditions.append("po.buyer_id = :client_id")
102
  params["client_id"] = filters.client_id
103
 
104
  if filters.client_name:
105
- conditions.append("mr.merchant_name ILIKE :client_name")
106
  params["client_name"] = f"%{filters.client_name}%"
107
 
108
  if filters.order_no:
109
- conditions.append("po.po_no ILIKE :order_no")
110
  params["order_no"] = f"%{filters.order_no}%"
111
 
112
  if filters.order_date_from:
113
- conditions.append("po.po_date >= :date_from")
114
  params["date_from"] = filters.order_date_from
115
 
116
  if filters.order_date_to:
117
- conditions.append("po.po_date <= :date_to")
118
  params["date_to"] = filters.order_date_to
119
 
120
- if filters.supplier_id:
121
- conditions.append("po.supplier_id = :supplier_id")
122
- params["supplier_id"] = filters.supplier_id
123
-
124
  if filters.min_total_amount:
125
- conditions.append("po.total_amt >= :min_total_amount")
126
  params["min_total_amount"] = filters.min_total_amount
127
 
128
  if filters.max_total_amount:
129
- conditions.append("po.total_amt <= :max_total_amount")
130
  params["max_total_amount"] = filters.max_total_amount
131
 
132
- if conditions:
133
- base_query += " AND " + " AND ".join(conditions)
134
-
135
- # Group by for aggregation
136
- base_query += """
137
- GROUP BY po.po_id, po.po_no, po.po_date, po.buyer_id,
138
- po.supplier_id, po.total_amt, po.status, po.remarks, mr.merchant_name
139
- """
140
-
141
- # Add status filter after grouping
142
- having_conditions = []
143
  if filters.status:
144
- having_conditions.append("status = :status")
145
  params["status"] = filters.status
146
 
147
  if filters.has_pending_qty:
148
- having_conditions.append("COALESCE(SUM(poi.ord_qty) - SUM(tsi.shipped_qty), SUM(poi.ord_qty)) > 0")
149
 
150
- if having_conditions:
151
- base_query += " HAVING " + " AND ".join(having_conditions)
152
 
153
  # Get total count
154
- count_query = f"SELECT COUNT(*) FROM ({base_query}) as counted"
155
  count_result = await self.db.execute(text(count_query), params)
156
  total_count = count_result.scalar()
157
 
158
  # Add ordering and pagination
159
- final_query = base_query + " ORDER BY po.po_date DESC LIMIT :limit OFFSET :skip"
160
  params.update({"limit": limit, "skip": skip})
161
- # logger.info(f"Final Query: {final_query} with params {params}")
162
  # Execute main query
163
  result = await self.db.execute(text(final_query), params)
164
  orders = result.fetchall()
@@ -358,7 +356,7 @@ class TradeSalesService:
358
  base_query += " HAVING " + " AND ".join(having_conditions)
359
 
360
  # Get total count
361
- count_query = f"SELECT COUNT(*) FROM ({base_query}) as counted"
362
  count_result = await self.db.execute(text(count_query), params)
363
  total_count = count_result.scalar()
364
 
@@ -543,7 +541,7 @@ class TradeSalesService:
543
 
544
  # Get next sequence number for today
545
  query = text("""
546
- SELECT COUNT(*) + 1 as next_seq
547
  FROM trans.scm_trade_shipment
548
  WHERE shipment_no LIKE :prefix
549
  """)
@@ -653,7 +651,7 @@ class TradeSalesService:
653
  """
654
 
655
  # Get total count
656
- count_query = f"SELECT COUNT(*) FROM ({base_query}) as counted"
657
  count_result = await self.db.execute(text(count_query), params)
658
  total_count = count_result.scalar()
659
 
 
51
 
52
  async def list_client_orders(
53
  self,
54
+ supplier_id: str,
55
  filters: ClientOrderFilters,
56
  skip: int = 0,
57
  limit: int = 100,
 
60
  """
61
  List client orders (distributor POs from cnf perspective) with projection support.
62
 
63
+ Args:
64
+ supplier_id: The supplier merchant ID to filter orders by
65
+ filters: Additional filtering criteria
66
+ skip: Pagination offset
67
+ limit: Number of records to return
68
+ projection_list: Optional list of fields to return
69
+
70
  Returns:
71
  Tuple of (orders_list, total_count)
72
  """
73
  try:
74
+ # Build CTE for aggregated order data
75
  base_query = """
76
+ WITH po_summary AS (
77
+ SELECT
78
+ po.po_id as order_id,
79
+ po.po_no as order_no,
80
+ po.po_date as order_date,
81
+ po.exp_delivery_dt as expected_delivery_date,
82
+ po.buyer_id as client_id,
83
+ mr.merchant_name AS client_name,
84
+ po.total_amt,
85
+ po.status as po_status,
86
+ COALESCE(SUM(poi.ord_qty), 0) as total_qty,
87
+ COALESCE(SUM(tsi.shipped_qty), 0) as shipped_qty,
88
+ COALESCE(SUM(poi.ord_qty) - SUM(tsi.shipped_qty), SUM(poi.ord_qty)) as pending_qty,
89
+ CASE
90
+ WHEN COALESCE(SUM(tsi.shipped_qty), 0) = 0 THEN 'pending'
91
+ WHEN COALESCE(SUM(tsi.shipped_qty), 0) < SUM(poi.ord_qty) THEN 'partial'
92
+ ELSE 'completed'
93
+ END AS fulfillment_status
94
+ FROM trans.scm_po po
95
+ LEFT JOIN trans.scm_po_item poi ON po.po_id = poi.po_id
96
+ LEFT JOIN trans.scm_trade_shipment_item tsi ON poi.po_item_id = tsi.po_item_id
97
+ LEFT JOIN trans.merchants_ref mr ON po.buyer_id = mr.merchant_id
98
+ WHERE po.supplier_id = :supplier_id
99
+ AND po.status != 'closed'
100
+ GROUP BY po.po_id, po.po_no, po.po_date, po.exp_delivery_dt,
101
+ po.buyer_id, po.supplier_id, po.total_amt, po.status, mr.merchant_name
102
+ )
103
+ SELECT * FROM po_summary
104
+ WHERE 1=1
105
  """
106
 
107
+ # Initialize params with supplier_id
108
+ params = {"supplier_id": supplier_id}
109
+
110
+ # Add filters to WHERE clause (applied after aggregation)
111
  conditions = []
 
112
 
113
  if filters.client_id:
114
+ conditions.append("client_id = :client_id")
115
  params["client_id"] = filters.client_id
116
 
117
  if filters.client_name:
118
+ conditions.append("client_name ILIKE :client_name")
119
  params["client_name"] = f"%{filters.client_name}%"
120
 
121
  if filters.order_no:
122
+ conditions.append("order_no ILIKE :order_no")
123
  params["order_no"] = f"%{filters.order_no}%"
124
 
125
  if filters.order_date_from:
126
+ conditions.append("order_date >= :date_from")
127
  params["date_from"] = filters.order_date_from
128
 
129
  if filters.order_date_to:
130
+ conditions.append("order_date <= :date_to")
131
  params["date_to"] = filters.order_date_to
132
 
 
 
 
 
133
  if filters.min_total_amount:
134
+ conditions.append("total_amt >= :min_total_amount")
135
  params["min_total_amount"] = filters.min_total_amount
136
 
137
  if filters.max_total_amount:
138
+ conditions.append("total_amt <= :max_total_amount")
139
  params["max_total_amount"] = filters.max_total_amount
140
 
 
 
 
 
 
 
 
 
 
 
 
141
  if filters.status:
142
+ conditions.append("fulfillment_status = :status")
143
  params["status"] = filters.status
144
 
145
  if filters.has_pending_qty:
146
+ conditions.append("pending_qty > 0")
147
 
148
+ if conditions:
149
+ base_query += " AND " + " AND ".join(conditions)
150
 
151
  # Get total count
152
+ count_query = f"SELECT COUNT(1) FROM ({base_query}) as counted"
153
  count_result = await self.db.execute(text(count_query), params)
154
  total_count = count_result.scalar()
155
 
156
  # Add ordering and pagination
157
+ final_query = base_query + " ORDER BY order_date DESC LIMIT :limit OFFSET :skip"
158
  params.update({"limit": limit, "skip": skip})
159
+
160
  # Execute main query
161
  result = await self.db.execute(text(final_query), params)
162
  orders = result.fetchall()
 
356
  base_query += " HAVING " + " AND ".join(having_conditions)
357
 
358
  # Get total count
359
+ count_query = f"SELECT COUNT(1) FROM ({base_query}) as counted"
360
  count_result = await self.db.execute(text(count_query), params)
361
  total_count = count_result.scalar()
362
 
 
541
 
542
  # Get next sequence number for today
543
  query = text("""
544
+ SELECT COUNT(1) + 1 as next_seq
545
  FROM trans.scm_trade_shipment
546
  WHERE shipment_no LIKE :prefix
547
  """)
 
651
  """
652
 
653
  # Get total count
654
+ count_query = f"SELECT COUNT(1) FROM ({base_query}) as counted"
655
  count_result = await self.db.execute(text(count_query), params)
656
  total_count = count_result.scalar()
657
 
check_existing_tables.py CHANGED
@@ -39,7 +39,7 @@ async def check_tables():
39
 
40
  # Check if there's any data in stock take table
41
  if any(t.table_name == 'scm_stock_take' for t in tables):
42
- result = await session.execute(text("SELECT COUNT(*) FROM trans.scm_stock_take"))
43
  count = result.scalar()
44
  print(f"\nStock take records: {count}")
45
 
@@ -57,7 +57,7 @@ async def check_tables():
57
 
58
  # Check if there's any data in stock adjustment table
59
  if any(t.table_name == 'scm_stock_adjustment' for t in tables):
60
- result = await session.execute(text("SELECT COUNT(*) FROM trans.scm_stock_adjustment"))
61
  count = result.scalar()
62
  print(f"\nStock adjustment records: {count}")
63
 
 
39
 
40
  # Check if there's any data in stock take table
41
  if any(t.table_name == 'scm_stock_take' for t in tables):
42
+ result = await session.execute(text("SELECT COUNT(1) FROM trans.scm_stock_take"))
43
  count = result.scalar()
44
  print(f"\nStock take records: {count}")
45
 
 
57
 
58
  # Check if there's any data in stock adjustment table
59
  if any(t.table_name == 'scm_stock_adjustment' for t in tables):
60
+ result = await session.execute(text("SELECT COUNT(1) FROM trans.scm_stock_adjustment"))
61
  count = result.scalar()
62
  print(f"\nStock adjustment records: {count}")
63
 
check_stock_take_data.py CHANGED
@@ -27,15 +27,15 @@ async def check_stock_take_data():
27
  )
28
 
29
  # Check compatibility view
30
- view_count = await conn.fetchval('SELECT COUNT(*) FROM trans.scm_stock_take')
31
  print(f'Records in scm_stock_take view: {view_count}')
32
 
33
  # Check master table
34
- master_count = await conn.fetchval('SELECT COUNT(*) FROM trans.scm_stock_take_master')
35
  print(f'Records in scm_stock_take_master: {master_count}')
36
 
37
  # Check details table
38
- details_count = await conn.fetchval('SELECT COUNT(*) FROM trans.scm_stock_take_details')
39
  print(f'Records in scm_stock_take_details: {details_count}')
40
 
41
  if view_count > 0:
 
27
  )
28
 
29
  # Check compatibility view
30
+ view_count = await conn.fetchval('SELECT COUNT(1) FROM trans.scm_stock_take')
31
  print(f'Records in scm_stock_take view: {view_count}')
32
 
33
  # Check master table
34
+ master_count = await conn.fetchval('SELECT COUNT(1) FROM trans.scm_stock_take_master')
35
  print(f'Records in scm_stock_take_master: {master_count}')
36
 
37
  # Check details table
38
+ details_count = await conn.fetchval('SELECT COUNT(1) FROM trans.scm_stock_take_details')
39
  print(f'Records in scm_stock_take_details: {details_count}')
40
 
41
  if view_count > 0:
create_stock_take_tables.sql CHANGED
@@ -466,17 +466,17 @@ WHERE schemaname = 'trans'
466
  ORDER BY tablename, indexname;
467
 
468
  -- Check sample data
469
- SELECT 'scm_stock' as table_name, COUNT(*) as record_count FROM trans.scm_stock
470
  UNION ALL
471
- SELECT 'scm_stock_ledger', COUNT(*) FROM trans.scm_stock_ledger
472
  UNION ALL
473
- SELECT 'scm_stock_adjustment', COUNT(*) FROM trans.scm_stock_adjustment
474
  UNION ALL
475
- SELECT 'scm_stock_adjustment_details', COUNT(*) FROM trans.scm_stock_adjustment_details
476
  UNION ALL
477
- SELECT 'scm_stock_take', COUNT(*) FROM trans.scm_stock_take
478
  UNION ALL
479
- SELECT 'scm_stock_take_details', COUNT(*) FROM trans.scm_stock_take_details;
480
 
481
  -- Sample query to show stock take master-detail relationship
482
  SELECT
 
466
  ORDER BY tablename, indexname;
467
 
468
  -- Check sample data
469
+ SELECT 'scm_stock' as table_name, COUNT(1) as record_count FROM trans.scm_stock
470
  UNION ALL
471
+ SELECT 'scm_stock_ledger', COUNT(1) FROM trans.scm_stock_ledger
472
  UNION ALL
473
+ SELECT 'scm_stock_adjustment', COUNT(1) FROM trans.scm_stock_adjustment
474
  UNION ALL
475
+ SELECT 'scm_stock_adjustment_details', COUNT(1) FROM trans.scm_stock_adjustment_details
476
  UNION ALL
477
+ SELECT 'scm_stock_take', COUNT(1) FROM trans.scm_stock_take
478
  UNION ALL
479
+ SELECT 'scm_stock_take_details', COUNT(1) FROM trans.scm_stock_take_details;
480
 
481
  -- Sample query to show stock take master-detail relationship
482
  SELECT
docs/database/migrations/migration_add_inventory_column.py CHANGED
@@ -132,7 +132,7 @@ async def run_migration():
132
  async with engine.begin() as conn:
133
  # Count records with inventory data
134
  result = await conn.execute(text("""
135
- SELECT COUNT(*)
136
  FROM trans.catalogue_ref
137
  WHERE inventory IS NOT NULL
138
  AND 'company_cuatro_beauty_ltd' = ANY(merchant_id)
 
132
  async with engine.begin() as conn:
133
  # Count records with inventory data
134
  result = await conn.execute(text("""
135
+ SELECT COUNT(1)
136
  FROM trans.catalogue_ref
137
  WHERE inventory IS NOT NULL
138
  AND 'company_cuatro_beauty_ltd' = ANY(merchant_id)
docs/database/migrations/migration_move_data_to_trans_schema.py CHANGED
@@ -56,7 +56,7 @@ async def backup_public_data(conn: asyncpg.Connection):
56
  SELECT * FROM public.{table}
57
  """)
58
 
59
- count = await conn.fetchval(f"SELECT COUNT(*) FROM backup_public.{table}")
60
  logger.info(f" βœ… Backed up {table}: {count} rows")
61
 
62
  except Exception as e:
@@ -74,7 +74,7 @@ async def migrate_table_data(conn: asyncpg.Connection, table_name: str):
74
 
75
  try:
76
  # Check if source table exists and has data
77
- public_count = await conn.fetchval(f"SELECT COUNT(*) FROM public.{table_name}")
78
 
79
  if public_count == 0:
80
  logger.info(f" πŸ“ {table_name}: No data to migrate")
@@ -129,7 +129,7 @@ async def migrate_table_data(conn: asyncpg.Connection, table_name: str):
129
  logger.info(f" βœ… {table_name}: Migrated {rows_inserted} rows successfully")
130
 
131
  # Verify migration
132
- trans_count = await conn.fetchval(f"SELECT COUNT(*) FROM trans.{table_name}")
133
  logger.info(f" πŸ“Š {table_name}: Trans schema now has {trans_count} rows")
134
 
135
  except Exception as e:
@@ -207,8 +207,8 @@ async def verify_migration(conn: asyncpg.Connection):
207
 
208
  for table in MIGRATION_TABLES:
209
  try:
210
- public_count = await conn.fetchval(f"SELECT COUNT(*) FROM public.{table}")
211
- trans_count = await conn.fetchval(f"SELECT COUNT(*) FROM trans.{table}")
212
 
213
  status = "βœ…" if trans_count >= public_count else "❌"
214
  verification_results.append({
 
56
  SELECT * FROM public.{table}
57
  """)
58
 
59
+ count = await conn.fetchval(f"SELECT COUNT(1) FROM backup_public.{table}")
60
  logger.info(f" βœ… Backed up {table}: {count} rows")
61
 
62
  except Exception as e:
 
74
 
75
  try:
76
  # Check if source table exists and has data
77
+ public_count = await conn.fetchval(f"SELECT COUNT(1) FROM public.{table_name}")
78
 
79
  if public_count == 0:
80
  logger.info(f" πŸ“ {table_name}: No data to migrate")
 
129
  logger.info(f" βœ… {table_name}: Migrated {rows_inserted} rows successfully")
130
 
131
  # Verify migration
132
+ trans_count = await conn.fetchval(f"SELECT COUNT(1) FROM trans.{table_name}")
133
  logger.info(f" πŸ“Š {table_name}: Trans schema now has {trans_count} rows")
134
 
135
  except Exception as e:
 
207
 
208
  for table in MIGRATION_TABLES:
209
  try:
210
+ public_count = await conn.fetchval(f"SELECT COUNT(1) FROM public.{table}")
211
+ trans_count = await conn.fetchval(f"SELECT COUNT(1) FROM trans.{table}")
212
 
213
  status = "βœ…" if trans_count >= public_count else "❌"
214
  verification_results.append({
docs/database/migrations/migration_trade_sales_tables.sql CHANGED
@@ -172,7 +172,7 @@ BEGIN
172
  RETURN QUERY
173
  SELECT
174
  'total_shipments'::VARCHAR(50) as metric_name,
175
- COUNT(*)::NUMERIC as metric_value,
176
  'count'::VARCHAR(20) as metric_unit
177
  FROM scm_trade_shipment ts
178
  WHERE (p_supplier_id IS NULL OR ts.supplier_id = p_supplier_id)
 
172
  RETURN QUERY
173
  SELECT
174
  'total_shipments'::VARCHAR(50) as metric_name,
175
+ COUNT(1)::NUMERIC as metric_value,
176
  'count'::VARCHAR(20) as metric_unit
177
  FROM scm_trade_shipment ts
178
  WHERE (p_supplier_id IS NULL OR ts.supplier_id = p_supplier_id)
docs/examples/ENHANCED_CATALOGUE_README.md CHANGED
@@ -203,7 +203,7 @@ db.scm_catalogue.findOne({"merchant_id": "company_cuatro_beauty_ltd"})
203
  ### PostgreSQL
204
  ```sql
205
  -- Check catalogue_ref count
206
- SELECT COUNT(*) FROM trans.catalogue_ref
207
  WHERE 'company_cuatro_beauty_ltd' = ANY(merchant_id);
208
 
209
  -- View sample record
 
203
  ### PostgreSQL
204
  ```sql
205
  -- Check catalogue_ref count
206
+ SELECT COUNT(1) FROM trans.catalogue_ref
207
  WHERE 'company_cuatro_beauty_ltd' = ANY(merchant_id);
208
 
209
  -- View sample record
docs/examples/STOCK_ADJUSTMENT_ENHANCED_DESIGN.md CHANGED
@@ -200,7 +200,7 @@ ORDER BY sam.total_adjustment_value DESC;
200
  ```sql
201
  SELECT
202
  sad.adj_type,
203
- COUNT(*) as adjustment_count,
204
  SUM(sad.qty) as total_qty,
205
  SUM(sad.adjustment_value) as total_value
206
  FROM trans.scm_stock_adjustment_details sad
 
200
  ```sql
201
  SELECT
202
  sad.adj_type,
203
+ COUNT(1) as adjustment_count,
204
  SUM(sad.qty) as total_qty,
205
  SUM(sad.adjustment_value) as total_value
206
  FROM trans.scm_stock_adjustment_details sad
docs/examples/check_postgres_duplicates.py CHANGED
@@ -59,13 +59,13 @@ class PostgreSQLChecker:
59
  # Find duplicate SKUs
60
  result = await session.execute(
61
  text("""
62
- SELECT sku, COUNT(*) as count,
63
  array_agg(catalogue_id) as catalogue_ids,
64
  array_agg(catalogue_code) as catalogue_codes
65
  FROM trans.catalogue_ref
66
  WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)
67
  GROUP BY sku
68
- HAVING COUNT(*) > 1
69
  ORDER BY count DESC
70
  """)
71
  )
 
59
  # Find duplicate SKUs
60
  result = await session.execute(
61
  text("""
62
+ SELECT sku, COUNT(1) as count,
63
  array_agg(catalogue_id) as catalogue_ids,
64
  array_agg(catalogue_code) as catalogue_codes
65
  FROM trans.catalogue_ref
66
  WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)
67
  GROUP BY sku
68
+ HAVING COUNT(1) > 1
69
  ORDER BY count DESC
70
  """)
71
  )
docs/examples/clean_and_verify_sync.py CHANGED
@@ -69,7 +69,7 @@ async def clean_and_verify():
69
  # Verify final count
70
  result = await conn.execute(
71
  text("""
72
- SELECT COUNT(*)
73
  FROM trans.catalogue_ref
74
  WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)
75
  """)
 
69
  # Verify final count
70
  result = await conn.execute(
71
  text("""
72
+ SELECT COUNT(1)
73
  FROM trans.catalogue_ref
74
  WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)
75
  """)
docs/examples/create_50_salon_catalogues.py CHANGED
@@ -273,7 +273,7 @@ async def create_50_catalogues():
273
  # PostgreSQL count
274
  async with async_session_maker() as pg_session:
275
  result = await pg_session.execute(
276
- text("SELECT COUNT(*) FROM catalogue_ref WHERE merchant_id = '01234567-89ab-cdef-0123-456789abcdef'")
277
  )
278
  pg_count = result.scalar()
279
  print(f"πŸ“Š PostgreSQL: {pg_count} catalogues")
 
273
  # PostgreSQL count
274
  async with async_session_maker() as pg_session:
275
  result = await pg_session.execute(
276
+ text("SELECT COUNT(1) FROM catalogue_ref WHERE merchant_id = '01234567-89ab-cdef-0123-456789abcdef'")
277
  )
278
  pg_count = result.scalar()
279
  print(f"πŸ“Š PostgreSQL: {pg_count} catalogues")
docs/examples/create_enhanced_salon_catalogues.py CHANGED
@@ -331,7 +331,7 @@ class EnhancedCatalogueGenerator:
331
  async with self.pg_session_factory() as session:
332
  from sqlalchemy import text
333
  result = await session.execute(
334
- text("SELECT COUNT(*) FROM trans.catalogue_ref WHERE 'company_cuatro_beauty_ltd' = ANY(merchant_id)")
335
  )
336
  pg_count = result.scalar()
337
  print(f"πŸ“Š PostgreSQL: {pg_count} catalogues found")
 
331
  async with self.pg_session_factory() as session:
332
  from sqlalchemy import text
333
  result = await session.execute(
334
+ text("SELECT COUNT(1) FROM trans.catalogue_ref WHERE 'company_cuatro_beauty_ltd' = ANY(merchant_id)")
335
  )
336
  pg_count = result.scalar()
337
  print(f"πŸ“Š PostgreSQL: {pg_count} catalogues found")
docs/examples/fix_missing_postgres_records.py CHANGED
@@ -154,7 +154,7 @@ class PostgreSQLFixer:
154
  async with self.pg_session_factory() as session:
155
  result = await session.execute(
156
  text("""
157
- SELECT COUNT(*)
158
  FROM trans.catalogue_ref
159
  WHERE 'company_cuatro_beauty_ltd' = ANY(merchant_id)
160
  """)
 
154
  async with self.pg_session_factory() as session:
155
  result = await session.execute(
156
  text("""
157
+ SELECT COUNT(1)
158
  FROM trans.catalogue_ref
159
  WHERE 'company_cuatro_beauty_ltd' = ANY(merchant_id)
160
  """)
docs/examples/setup_master_detail.py CHANGED
@@ -23,7 +23,7 @@ async def setup_master_detail():
23
  print("πŸš€ Setting up master-detail structure...")
24
 
25
  # Check if old table exists and has data
26
- result = await session.execute(text("SELECT COUNT(*) FROM trans.scm_stock_take"))
27
  old_count = result.scalar()
28
 
29
  if old_count == 0:
 
23
  print("πŸš€ Setting up master-detail structure...")
24
 
25
  # Check if old table exists and has data
26
+ result = await session.execute(text("SELECT COUNT(1) FROM trans.scm_stock_take"))
27
  old_count = result.scalar()
28
 
29
  if old_count == 0:
docs/examples/simple_verification.py CHANGED
@@ -32,7 +32,7 @@ async def simple_verify():
32
  async with engine.begin() as conn:
33
  # Total count
34
  result = await conn.execute(text("""
35
- SELECT COUNT(*)
36
  FROM trans.catalogue_ref
37
  WHERE 'company_cuatro_beauty_ltd' = ANY(merchant_id)
38
  """))
@@ -40,7 +40,7 @@ async def simple_verify():
40
 
41
  # Count with inventory
42
  result = await conn.execute(text("""
43
- SELECT COUNT(*)
44
  FROM trans.catalogue_ref
45
  WHERE inventory IS NOT NULL
46
  AND 'company_cuatro_beauty_ltd' = ANY(merchant_id)
 
32
  async with engine.begin() as conn:
33
  # Total count
34
  result = await conn.execute(text("""
35
+ SELECT COUNT(1)
36
  FROM trans.catalogue_ref
37
  WHERE 'company_cuatro_beauty_ltd' = ANY(merchant_id)
38
  """))
 
40
 
41
  # Count with inventory
42
  result = await conn.execute(text("""
43
+ SELECT COUNT(1)
44
  FROM trans.catalogue_ref
45
  WHERE inventory IS NOT NULL
46
  AND 'company_cuatro_beauty_ltd' = ANY(merchant_id)
docs/examples/sync_mongo_to_postgres.py CHANGED
@@ -47,7 +47,7 @@ async def sync_catalogues_to_postgres():
47
  # Check current PostgreSQL count
48
  async with async_session_maker() as pg_session:
49
  result = await pg_session.execute(
50
- text("SELECT COUNT(*) FROM trans.catalogue_ref WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)")
51
  )
52
  pg_count_before = result.scalar()
53
  print(f"πŸ“Š PostgreSQL before sync: {pg_count_before} records")
@@ -166,7 +166,7 @@ async def sync_catalogues_to_postgres():
166
  # Verify final count
167
  async with async_session_maker() as pg_session:
168
  result = await pg_session.execute(
169
- text("SELECT COUNT(*) FROM trans.catalogue_ref WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)")
170
  )
171
  pg_count_after = result.scalar()
172
 
 
47
  # Check current PostgreSQL count
48
  async with async_session_maker() as pg_session:
49
  result = await pg_session.execute(
50
+ text("SELECT COUNT(1) FROM trans.catalogue_ref WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)")
51
  )
52
  pg_count_before = result.scalar()
53
  print(f"πŸ“Š PostgreSQL before sync: {pg_count_before} records")
 
166
  # Verify final count
167
  async with async_session_maker() as pg_session:
168
  result = await pg_session.execute(
169
+ text("SELECT COUNT(1) FROM trans.catalogue_ref WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)")
170
  )
171
  pg_count_after = result.scalar()
172
 
docs/examples/verify_enhanced_data.py CHANGED
@@ -94,7 +94,7 @@ class DataVerifier:
94
  async with self.pg_session_factory() as session:
95
  # Count synced catalogues
96
  count_result = await session.execute(
97
- text("SELECT COUNT(*) FROM trans.catalogue_ref WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)")
98
  )
99
  pg_count = count_result.scalar()
100
  print(f"πŸ“Š Synced catalogues: {pg_count}")
 
94
  async with self.pg_session_factory() as session:
95
  # Count synced catalogues
96
  count_result = await session.execute(
97
+ text("SELECT COUNT(1) FROM trans.catalogue_ref WHERE '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)")
98
  )
99
  pg_count = count_result.scalar()
100
  print(f"πŸ“Š Synced catalogues: {pg_count}")
docs/examples/verify_inventory_migration.py CHANGED
@@ -33,7 +33,7 @@ async def verify_migration():
33
  # Check PostgreSQL
34
  async with engine.begin() as conn:
35
  result = await conn.execute(text("""
36
- SELECT COUNT(*)
37
  FROM trans.catalogue_ref
38
  WHERE inventory IS NOT NULL
39
  AND '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)
 
33
  # Check PostgreSQL
34
  async with engine.begin() as conn:
35
  result = await conn.execute(text("""
36
+ SELECT COUNT(1)
37
  FROM trans.catalogue_ref
38
  WHERE inventory IS NOT NULL
39
  AND '01234567-89ab-cdef-0123-456789abcdef' = ANY(merchant_id)
docs/implementation-summaries/PRICING_LEVELS_COMPLETE_SOLUTION.md CHANGED
@@ -213,7 +213,7 @@ DEBUG: Catalogue upserted to PostgreSQL with pricing_levels
213
  ### Performance Queries
214
  ```sql
215
  -- Count catalogues with pricing_levels
216
- SELECT COUNT(*) FROM trans.catalogue_ref WHERE pricing_levels IS NOT NULL;
217
 
218
  -- Find catalogues by currency
219
  SELECT catalogue_id, catalogue_name
 
213
  ### Performance Queries
214
  ```sql
215
  -- Count catalogues with pricing_levels
216
+ SELECT COUNT(1) FROM trans.catalogue_ref WHERE pricing_levels IS NOT NULL;
217
 
218
  -- Find catalogues by currency
219
  SELECT catalogue_id, catalogue_name
fix_schema_issues.py CHANGED
@@ -117,7 +117,7 @@ async def fix_schema_issues():
117
  print("\n5. Setting default values for rcvd_qty...")
118
 
119
  null_rcvd_qty_count = await conn.fetchval("""
120
- SELECT COUNT(*) FROM trans.scm_po_item WHERE rcvd_qty IS NULL
121
  """)
122
 
123
  if null_rcvd_qty_count > 0:
 
117
  print("\n5. Setting default values for rcvd_qty...")
118
 
119
  null_rcvd_qty_count = await conn.fetchval("""
120
+ SELECT COUNT(1) FROM trans.scm_po_item WHERE rcvd_qty IS NULL
121
  """)
122
 
123
  if null_rcvd_qty_count > 0:
migrate_tables_to_trans_schema.sql CHANGED
@@ -36,14 +36,14 @@ WHERE tablename LIKE 'scm_%'
36
  ORDER BY schemaname, tablename;
37
 
38
  -- Show table counts to verify data integrity
39
- SELECT 'scm_po' as table_name, COUNT(*) as record_count FROM trans.scm_po
40
  UNION ALL
41
- SELECT 'scm_po_item' as table_name, COUNT(*) as record_count FROM trans.scm_po_item
42
  UNION ALL
43
- SELECT 'scm_po_status_log' as table_name, COUNT(*) as record_count FROM trans.scm_po_status_log
44
  UNION ALL
45
- SELECT 'scm_grn' as table_name, COUNT(*) as record_count FROM trans.scm_grn
46
  UNION ALL
47
- SELECT 'scm_grn_item' as table_name, COUNT(*) as record_count FROM trans.scm_grn_item
48
  UNION ALL
49
- SELECT 'scm_grn_issue' as table_name, COUNT(*) as record_count FROM trans.scm_grn_issue;
 
36
  ORDER BY schemaname, tablename;
37
 
38
  -- Show table counts to verify data integrity
39
+ SELECT 'scm_po' as table_name, COUNT(1) as record_count FROM trans.scm_po
40
  UNION ALL
41
+ SELECT 'scm_po_item' as table_name, COUNT(1) as record_count FROM trans.scm_po_item
42
  UNION ALL
43
+ SELECT 'scm_po_status_log' as table_name, COUNT(1) as record_count FROM trans.scm_po_status_log
44
  UNION ALL
45
+ SELECT 'scm_grn' as table_name, COUNT(1) as record_count FROM trans.scm_grn
46
  UNION ALL
47
+ SELECT 'scm_grn_item' as table_name, COUNT(1) as record_count FROM trans.scm_grn_item
48
  UNION ALL
49
+ SELECT 'scm_grn_issue' as table_name, COUNT(1) as record_count FROM trans.scm_grn_issue;
migrate_to_master_detail.py CHANGED
@@ -62,7 +62,7 @@ class StockTakeMigration:
62
  # Check if new tables exist
63
  result = await session.execute(text("""
64
  SELECT
65
- COUNT(*) as table_count
66
  FROM information_schema.tables
67
  WHERE table_schema = 'trans'
68
  AND table_name IN ('scm_stock_take_master', 'scm_stock_take_details',
@@ -440,21 +440,21 @@ class StockTakeMigration:
440
  try:
441
  async with async_session() as session:
442
  # Count old records
443
- old_st_result = await session.execute(text("SELECT COUNT(*) FROM trans.scm_stock_take"))
444
  old_st_count = old_st_result.scalar()
445
 
446
  try:
447
- old_adj_result = await session.execute(text("SELECT COUNT(*) FROM trans.scm_stock_adjustment"))
448
  old_adj_count = old_adj_result.scalar()
449
  except:
450
  old_adj_count = 0
451
 
452
  # Count new records
453
- new_st_result = await session.execute(text("SELECT COUNT(*) FROM trans.scm_stock_take_details"))
454
  new_st_count = new_st_result.scalar()
455
 
456
  try:
457
- new_adj_result = await session.execute(text("SELECT COUNT(*) FROM trans.scm_stock_adjustment_details"))
458
  new_adj_count = new_adj_result.scalar()
459
  except:
460
  new_adj_count = 0
 
62
  # Check if new tables exist
63
  result = await session.execute(text("""
64
  SELECT
65
+ COUNT(1) as table_count
66
  FROM information_schema.tables
67
  WHERE table_schema = 'trans'
68
  AND table_name IN ('scm_stock_take_master', 'scm_stock_take_details',
 
440
  try:
441
  async with async_session() as session:
442
  # Count old records
443
+ old_st_result = await session.execute(text("SELECT COUNT(1) FROM trans.scm_stock_take"))
444
  old_st_count = old_st_result.scalar()
445
 
446
  try:
447
+ old_adj_result = await session.execute(text("SELECT COUNT(1) FROM trans.scm_stock_adjustment"))
448
  old_adj_count = old_adj_result.scalar()
449
  except:
450
  old_adj_count = 0
451
 
452
  # Count new records
453
+ new_st_result = await session.execute(text("SELECT COUNT(1) FROM trans.scm_stock_take_details"))
454
  new_st_count = new_st_result.scalar()
455
 
456
  try:
457
+ new_adj_result = await session.execute(text("SELECT COUNT(1) FROM trans.scm_stock_adjustment_details"))
458
  new_adj_count = new_adj_result.scalar()
459
  except:
460
  new_adj_count = 0
migrate_to_trans_schema.py CHANGED
@@ -85,13 +85,13 @@ async def migrate_to_trans_schema():
85
  print(f" Moving {table_name}...")
86
 
87
  # Get record count before move
88
- count_before = await conn.fetchval(f"SELECT COUNT(*) FROM public.{table_name}")
89
 
90
  # Move table
91
  await conn.execute(f"ALTER TABLE public.{table_name} SET SCHEMA trans")
92
 
93
  # Verify record count after move
94
- count_after = await conn.fetchval(f"SELECT COUNT(*) FROM trans.{table_name}")
95
 
96
  if count_before == count_after:
97
  print(f" βœ… {table_name} moved successfully ({count_after} records)")
@@ -120,7 +120,7 @@ async def migrate_to_trans_schema():
120
 
121
  for table_name in tables_to_move:
122
  try:
123
- count = await conn.fetchval(f"SELECT COUNT(*) FROM trans.{table_name}")
124
  print(f" - {table_name}: {count} records")
125
  except Exception as e:
126
  print(f" - {table_name}: Error - {e}")
 
85
  print(f" Moving {table_name}...")
86
 
87
  # Get record count before move
88
+ count_before = await conn.fetchval(f"SELECT COUNT(1) FROM public.{table_name}")
89
 
90
  # Move table
91
  await conn.execute(f"ALTER TABLE public.{table_name} SET SCHEMA trans")
92
 
93
  # Verify record count after move
94
+ count_after = await conn.fetchval(f"SELECT COUNT(1) FROM trans.{table_name}")
95
 
96
  if count_before == count_after:
97
  print(f" βœ… {table_name} moved successfully ({count_after} records)")
 
120
 
121
  for table_name in tables_to_move:
122
  try:
123
+ count = await conn.fetchval(f"SELECT COUNT(1) FROM trans.{table_name}")
124
  print(f" - {table_name}: {count} records")
125
  except Exception as e:
126
  print(f" - {table_name}: Error - {e}")
quick_fix_compatibility.py CHANGED
@@ -26,7 +26,7 @@ async def create_compatibility_layer():
26
  print("Checking if new master-detail tables exist...")
27
 
28
  result = await session.execute(text("""
29
- SELECT COUNT(*) as table_count
30
  FROM information_schema.tables
31
  WHERE table_schema = 'trans'
32
  AND table_name IN ('scm_stock_take_master', 'scm_stock_take_details')
 
26
  print("Checking if new master-detail tables exist...")
27
 
28
  result = await session.execute(text("""
29
+ SELECT COUNT(1) as table_count
30
  FROM information_schema.tables
31
  WHERE table_schema = 'trans'
32
  AND table_name IN ('scm_stock_take_master', 'scm_stock_take_details')
test_simplified_stock_take_approval.py CHANGED
@@ -121,21 +121,21 @@ async def test_simplified_approval_workflow():
121
 
122
  # Pending approval count (should be 0 since we approved it)
123
  pending_count = await conn.fetchval("""
124
- SELECT COUNT(*) FROM trans.scm_stock_take_master
125
  WHERE merchant_id = $1 AND status = 'submitted'
126
  """, merchant_id)
127
 
128
  # Today's approvals
129
  today = date.today()
130
  approved_today = await conn.fetchval("""
131
- SELECT COUNT(*) FROM trans.scm_stock_take_master
132
  WHERE merchant_id = $1 AND status = 'approved'
133
  AND DATE(approved_at) = $2
134
  """, merchant_id, today)
135
 
136
  # Approved items (final state)
137
  approved_total = await conn.fetchval("""
138
- SELECT COUNT(*) FROM trans.scm_stock_take_master
139
  WHERE merchant_id = $1 AND status = 'approved'
140
  """, merchant_id)
141
 
 
121
 
122
  # Pending approval count (should be 0 since we approved it)
123
  pending_count = await conn.fetchval("""
124
+ SELECT COUNT(1) FROM trans.scm_stock_take_master
125
  WHERE merchant_id = $1 AND status = 'submitted'
126
  """, merchant_id)
127
 
128
  # Today's approvals
129
  today = date.today()
130
  approved_today = await conn.fetchval("""
131
+ SELECT COUNT(1) FROM trans.scm_stock_take_master
132
  WHERE merchant_id = $1 AND status = 'approved'
133
  AND DATE(approved_at) = $2
134
  """, merchant_id, today)
135
 
136
  # Approved items (final state)
137
  approved_total = await conn.fetchval("""
138
+ SELECT COUNT(1) FROM trans.scm_stock_take_master
139
  WHERE merchant_id = $1 AND status = 'approved'
140
  """, merchant_id)
141
 
test_stock_take_approval_workflow.py CHANGED
@@ -170,14 +170,14 @@ async def test_approval_workflow():
170
 
171
  # Pending approval count
172
  pending_count = await conn.fetchval("""
173
- SELECT COUNT(*) FROM trans.scm_stock_take_master
174
  WHERE merchant_id = $1 AND status = 'submitted'
175
  """, merchant_id)
176
 
177
  # Today's approvals
178
  today = date.today()
179
  approved_today = await conn.fetchval("""
180
- SELECT COUNT(*) FROM trans.scm_stock_take_master
181
  WHERE merchant_id = $1 AND status = 'approved'
182
  AND DATE(approved_at) = $2
183
  """, merchant_id, today)
@@ -191,7 +191,7 @@ async def test_approval_workflow():
191
 
192
  # High variance count (>1000)
193
  high_variance = await conn.fetchval("""
194
- SELECT COUNT(*) FROM trans.scm_stock_take_master
195
  WHERE merchant_id = $1 AND status = 'submitted'
196
  AND ABS(total_variance_value) > 1000
197
  """, merchant_id)
 
170
 
171
  # Pending approval count
172
  pending_count = await conn.fetchval("""
173
+ SELECT COUNT(1) FROM trans.scm_stock_take_master
174
  WHERE merchant_id = $1 AND status = 'submitted'
175
  """, merchant_id)
176
 
177
  # Today's approvals
178
  today = date.today()
179
  approved_today = await conn.fetchval("""
180
+ SELECT COUNT(1) FROM trans.scm_stock_take_master
181
  WHERE merchant_id = $1 AND status = 'approved'
182
  AND DATE(approved_at) = $2
183
  """, merchant_id, today)
 
191
 
192
  # High variance count (>1000)
193
  high_variance = await conn.fetchval("""
194
+ SELECT COUNT(1) FROM trans.scm_stock_take_master
195
  WHERE merchant_id = $1 AND status = 'submitted'
196
  AND ABS(total_variance_value) > 1000
197
  """, merchant_id)
tests/test_properties_catalogue_sync.py CHANGED
@@ -531,7 +531,7 @@ def test_property_idempotent_catalogue_sync(
531
 
532
  # Verify only one record exists
533
  count = await pg_conn.fetchval(
534
- "SELECT COUNT(*) FROM trans.catalogue_ref WHERE catalogue_id = $1",
535
  catalogue_id
536
  )
537
  assert count == 1, f"Should have exactly 1 record, found {count}"
@@ -607,7 +607,7 @@ def test_property_catalogue_upsert_behavior(
607
 
608
  # Verify only one record exists with updated data
609
  count = await pg_conn.fetchval(
610
- "SELECT COUNT(*) FROM trans.catalogue_ref WHERE catalogue_id = $1",
611
  catalogue_id
612
  )
613
  assert count == 1, "Should have exactly 1 record (upsert, not insert)"
 
531
 
532
  # Verify only one record exists
533
  count = await pg_conn.fetchval(
534
+ "SELECT COUNT(1) FROM trans.catalogue_ref WHERE catalogue_id = $1",
535
  catalogue_id
536
  )
537
  assert count == 1, f"Should have exactly 1 record, found {count}"
 
607
 
608
  # Verify only one record exists with updated data
609
  count = await pg_conn.fetchval(
610
+ "SELECT COUNT(1) FROM trans.catalogue_ref WHERE catalogue_id = $1",
611
  catalogue_id
612
  )
613
  assert count == 1, "Should have exactly 1 record (upsert, not insert)"
tests/test_properties_employee_sync.py CHANGED
@@ -468,7 +468,7 @@ def test_property_idempotent_employee_sync(
468
 
469
  # Verify only one record exists
470
  count = await pg_conn.fetchval(
471
- "SELECT COUNT(*) FROM trans.employees_ref WHERE employee_id = $1",
472
  user_id
473
  )
474
  assert count == 1, f"Should have exactly 1 record, found {count}"
@@ -542,7 +542,7 @@ def test_property_employee_upsert_behavior(
542
 
543
  # Verify only one record exists with updated data
544
  count = await pg_conn.fetchval(
545
- "SELECT COUNT(*) FROM trans.employees_ref WHERE employee_id = $1",
546
  user_id
547
  )
548
  assert count == 1, "Should have exactly 1 record (upsert, not insert)"
 
468
 
469
  # Verify only one record exists
470
  count = await pg_conn.fetchval(
471
+ "SELECT COUNT(1) FROM trans.employees_ref WHERE employee_id = $1",
472
  user_id
473
  )
474
  assert count == 1, f"Should have exactly 1 record, found {count}"
 
542
 
543
  # Verify only one record exists with updated data
544
  count = await pg_conn.fetchval(
545
+ "SELECT COUNT(1) FROM trans.employees_ref WHERE employee_id = $1",
546
  user_id
547
  )
548
  assert count == 1, "Should have exactly 1 record (upsert, not insert)"
tests/test_properties_merchant_sync.py CHANGED
@@ -501,7 +501,7 @@ def test_property_idempotent_merchant_sync(
501
 
502
  # Verify only one record exists
503
  count = await pg_conn.fetchval(
504
- "SELECT COUNT(*) FROM trans.merchants_ref WHERE merchant_id = $1",
505
  merchant_id
506
  )
507
  assert count == 1, f"Should have exactly 1 record, found {count}"
@@ -572,7 +572,7 @@ def test_property_merchant_upsert_behavior(
572
 
573
  # Verify only one record exists with updated data
574
  count = await pg_conn.fetchval(
575
- "SELECT COUNT(*) FROM trans.merchants_ref WHERE merchant_id = $1",
576
  merchant_id
577
  )
578
  assert count == 1, "Should have exactly 1 record (upsert, not insert)"
 
501
 
502
  # Verify only one record exists
503
  count = await pg_conn.fetchval(
504
+ "SELECT COUNT(1) FROM trans.merchants_ref WHERE merchant_id = $1",
505
  merchant_id
506
  )
507
  assert count == 1, f"Should have exactly 1 record, found {count}"
 
572
 
573
  # Verify only one record exists with updated data
574
  count = await pg_conn.fetchval(
575
+ "SELECT COUNT(1) FROM trans.merchants_ref WHERE merchant_id = $1",
576
  merchant_id
577
  )
578
  assert count == 1, "Should have exactly 1 record (upsert, not insert)"
verify_trade_invoice_cleanup.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Trade Invoice Cleanup Verification Script
4
+ Verifies that the cleanup improvements work correctly
5
+ """
6
+ import sys
7
+ import os
8
+ from decimal import Decimal
9
+
10
+ # Add the app directory to Python path
11
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '.'))
12
+
13
+ try:
14
+ # Test imports
15
+ from app.trade_invoices.constants import (
16
+ InvoiceStatus, InvoiceAction, ValidationErrorCodes,
17
+ VALID_TRANSITIONS, ACTION_STATUS_MAP, ACTION_MESSAGES
18
+ )
19
+ from app.trade_invoices.utils import (
20
+ clean_filters, generate_invoice_number, calculate_line_totals,
21
+ build_projection_query, validate_projection_fields,
22
+ ALLOWED_INVOICE_PROJECTION_FIELDS
23
+ )
24
+ print("βœ… All imports successful")
25
+
26
+ # Test constants
27
+ assert InvoiceStatus.DRAFT == "draft"
28
+ assert InvoiceAction.SUBMIT == "submit"
29
+ assert ValidationErrorCodes.PO_NOT_FOUND == "PO_NOT_FOUND"
30
+ print("βœ… Constants working correctly")
31
+
32
+ # Test state machine
33
+ assert InvoiceAction.SUBMIT in VALID_TRANSITIONS[InvoiceStatus.DRAFT]
34
+ assert ACTION_STATUS_MAP[InvoiceAction.SUBMIT] == InvoiceStatus.SUBMITTED
35
+ assert "submitted successfully" in ACTION_MESSAGES[InvoiceAction.SUBMIT]
36
+ print("βœ… State machine logic working correctly")
37
+
38
+ # Test utility functions
39
+ # Test filter cleaning
40
+ filters = {"supplier_id": "SUPP001", "buyer_id": None, "status": ""}
41
+ cleaned = clean_filters(filters)
42
+ assert cleaned == {"supplier_id": "SUPP001"}
43
+ print("βœ… Filter cleaning working correctly")
44
+
45
+ # Test invoice number generation
46
+ invoice_no = generate_invoice_number(2024, 1)
47
+ assert invoice_no == "INV-2024-000001"
48
+ print("βœ… Invoice number generation working correctly")
49
+
50
+ # Test line total calculations
51
+ totals = calculate_line_totals(
52
+ quantity=Decimal("10"),
53
+ unit_price=Decimal("100"),
54
+ discount_rate=Decimal("10"),
55
+ tax_rate=Decimal("18")
56
+ )
57
+ assert totals["line_net"] == Decimal("1000")
58
+ assert totals["discount_amt"] == Decimal("100")
59
+ assert totals["tax_amt"] == Decimal("162")
60
+ assert totals["line_total"] == Decimal("1062")
61
+ print("βœ… Line total calculations working correctly")
62
+
63
+ # Test projection query building
64
+ fields = ["invoice_id", "invoice_no", "status"]
65
+ query = build_projection_query(fields, "i")
66
+ expected = "i.invoice_id, i.invoice_no, i.status"
67
+ assert query == expected
68
+ print("βœ… Projection query building working correctly")
69
+
70
+ # Test projection field validation
71
+ valid_fields = ["invoice_id", "invoice_no", "status"]
72
+ invalid = validate_projection_fields(valid_fields, ALLOWED_INVOICE_PROJECTION_FIELDS)
73
+ assert invalid == []
74
+
75
+ mixed_fields = ["invoice_id", "invalid_field", "status"]
76
+ invalid = validate_projection_fields(mixed_fields, ALLOWED_INVOICE_PROJECTION_FIELDS)
77
+ assert "invalid_field" in invalid
78
+ print("βœ… Projection field validation working correctly")
79
+
80
+ print("\nπŸŽ‰ ALL VERIFICATION TESTS PASSED!")
81
+ print("βœ… Trade Invoice cleanup is working correctly")
82
+ print("βœ… All constants, utilities, and logic verified")
83
+ print("βœ… Projection list compliance maintained")
84
+ print("βœ… No breaking changes detected")
85
+
86
+ except ImportError as e:
87
+ print(f"❌ Import error: {e}")
88
+ sys.exit(1)
89
+ except AssertionError as e:
90
+ print(f"❌ Assertion error: {e}")
91
+ sys.exit(1)
92
+ except Exception as e:
93
+ print(f"❌ Unexpected error: {e}")
94
+ sys.exit(1)