MukeshKapoor25 commited on
Commit
2bbde03
·
2 Parent(s): 69f2a47 2b6a8be

Merge https://huggingface.co/spaces/cuatrolabs/cuatrolabs-scm-ms

Browse files
Files changed (34) hide show
  1. app/catalogues/controllers/router.py +1 -0
  2. app/catalogues/schemas/schema.py +10 -1
  3. app/catalogues/services/service.py +37 -16
  4. app/employees/services/service.py +47 -7
  5. app/inventory/adjustments/controllers/router.py +4 -3
  6. app/inventory/adjustments/schemas/schema.py +1 -3
  7. app/inventory/adjustments/services/service.py +127 -76
  8. app/inventory/stock/models/model.py +5 -0
  9. app/inventory/stock/services/service.py +35 -25
  10. app/po_returns/controllers/router.py +4 -2
  11. app/po_returns/models/model.py +18 -3
  12. app/po_returns/schemas/schema.py +2 -6
  13. app/po_returns/services/service.py +111 -7
  14. app/purchases/orders/controllers/router.py +5 -0
  15. app/purchases/orders/models/model.py +7 -1
  16. app/purchases/orders/schemas/schema.py +5 -3
  17. app/purchases/orders/services/service.py +21 -8
  18. app/purchases/receipts/controllers/router.py +5 -5
  19. app/purchases/receipts/models/model.py +5 -2
  20. app/purchases/receipts/schemas/schema.py +2 -1
  21. app/purchases/receipts/services/service.py +123 -40
  22. app/system_users/schemas/schema.py +14 -13
  23. app/system_users/services/service.py +2 -2
  24. app/trade_invoices/controllers/router.py +9 -3
  25. app/trade_invoices/models/model.py +3 -0
  26. app/trade_invoices/services/service.py +30 -8
  27. app/trade_invoices/utils.py +1 -1
  28. app/trade_relationships/controllers/router.py +3 -2
  29. app/trade_relationships/models/model.py +4 -1
  30. app/trade_relationships/schemas/schema.py +1 -4
  31. app/trade_relationships/services/service.py +37 -26
  32. app/trade_returns/services/service.py +8 -3
  33. app/trade_sales/schemas/schema.py +2 -1
  34. app/trade_sales/services/service.py +21 -2
app/catalogues/controllers/router.py CHANGED
@@ -320,6 +320,7 @@ async def update_item(
320
  catalogue_id,
321
  update_data,
322
  current_user.user_id,
 
323
  merchant_id=current_user.merchant_id
324
  )
325
 
 
320
  catalogue_id,
321
  update_data,
322
  current_user.user_id,
323
+ current_user.username,
324
  merchant_id=current_user.merchant_id
325
  )
326
 
app/catalogues/schemas/schema.py CHANGED
@@ -732,7 +732,16 @@ class Catalogue(BaseModel):
732
  inventory: Optional[Inventory] = Field(None, description="Inventory management configuration")
733
  tax: Optional[Tax] = Field(None, description="Tax information (HSN, GST)")
734
  media: Optional[Media] = Field(None, description="Product images and media")
735
- meta: Optional[Meta] = Field(None, description="Metadata (timestamps, status, created by)")
 
 
 
 
 
 
 
 
 
736
 
737
  @field_validator("catalogue_code")
738
  def validate_catalogue_code(cls, v):
 
732
  inventory: Optional[Inventory] = Field(None, description="Inventory management configuration")
733
  tax: Optional[Tax] = Field(None, description="Tax information (HSN, GST)")
734
  media: Optional[Media] = Field(None, description="Product images and media")
735
+ created_by: Optional[constr(min_length=3, max_length=100, strip_whitespace=True)] = Field(
736
+ None,
737
+ description="User ID of the creator of this catalogue item"
738
+ )
739
+ updated_by: Optional[constr(min_length=3, max_length=100, strip_whitespace=True)] = Field(None, description="User ID of the last updater of this catalogue item")
740
+ updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
741
+ created_at: Optional[datetime] = Field(None, description="Timestamp of creation")
742
+ created_by_username: Optional[constr(min_length=3, max_length=100, strip_whitespace=True)] = Field(None, description="Username of the creator of this catalogue item")
743
+ updated_by_username: Optional[constr(min_length=3, max_length=100, strip_whitespace=True)] = Field(None, description="Username of the creator of this catalogue item")
744
+ status: CatalogueStatus = Field(CatalogueStatus.ACTIVE, description="Status of the catalogue item")
745
 
746
  @field_validator("catalogue_code")
747
  def validate_catalogue_code(cls, v):
app/catalogues/services/service.py CHANGED
@@ -7,6 +7,7 @@ from fastapi import HTTPException, status
7
  from motor.core import AgnosticDatabase as AsyncIOMotorDatabase
8
  from app.catalogues.schemas.schema import Catalogue
9
  from app.catalogues.constants import SCM_CATALOGUE_COLLECTION
 
10
  from app.dependencies.auth import TokenUser
11
  from app.utils.util import flatten_update_data
12
  from app.catalogues.utils import (
@@ -227,7 +228,22 @@ class CatalogueService:
227
 
228
  items = []
229
  async for doc in cursor:
230
- items.append(doc if projection_list else Catalogue(**doc))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  return items, total_count
233
 
@@ -351,13 +367,13 @@ class CatalogueService:
351
  inventory=data.inventory,
352
  tax=data.tax,
353
  media=data.media,
354
-
355
- meta={
356
- "created_by": current_user.user_id,
357
- "created_by_user_name": current_user.username,
358
- "created_at": datetime.utcnow(),
359
- "status": "Active",
360
- }
361
  )
362
 
363
  # Insert into MongoDB
@@ -384,7 +400,7 @@ class CatalogueService:
384
  detail="Failed to create catalogue item"
385
  )
386
 
387
- async def update_catalogue_item(self, catalogue_id: str, update_data: dict, updated_by: str, merchant_id: str = None):
388
  """Update an existing catalogue item with merchant filtering."""
389
  try:
390
  # Check if the catalogue exists with merchant filtering
@@ -439,8 +455,9 @@ class CatalogueService:
439
  update_data_flat = flatten_update_data(update_data)
440
 
441
  # Add metadata
442
- update_data_flat["meta.updated_by"] = updated_by
443
- update_data_flat["meta.updated_at"] = datetime.utcnow()
 
444
 
445
  # Update MongoDB with merchant filtering
446
  update_query = {"catalogue_id": catalogue_id}
@@ -693,7 +710,9 @@ class CatalogueService:
693
  if projection_list:
694
  return doc
695
  else:
696
- return Catalogue(**doc)
 
 
697
 
698
  except HTTPException:
699
  raise
@@ -877,6 +896,7 @@ class CatalogueService:
877
  cr.barcode_number,
878
  cr.hsn_code,
879
  cr.gst_rate,
 
880
  cr.mrp,
881
  COALESCE(cp.cost_price, cr.base_price) AS cost_price,
882
  COALESCE(cp.trade_margin, 0) AS trade_margin,
@@ -1000,17 +1020,18 @@ class CatalogueService:
1000
  "lead_time_days": int(row.lead_time_days) if row.lead_time_days is not None else 7,
1001
  "max_stock_level": int(row.max_stock_level) if row.max_stock_level is not None else 1000,
1002
  "unit": row.unit if row.unit else "PCS",
 
1003
  }
1004
-
1005
  # Apply projection if specified
1006
  if projection_list:
1007
  projected_item = {}
1008
  for field in projection_list:
1009
- if field in row_dict:
1010
- projected_item[field] = row_dict[field]
1011
  items.append(projected_item)
1012
  else:
1013
- items.append(row_dict)
1014
 
1015
  logger.info(f"Retrieved {len(items)} merchant catalogue items for merchant {merchant_id}")
1016
  return items, total_count
 
7
  from motor.core import AgnosticDatabase as AsyncIOMotorDatabase
8
  from app.catalogues.schemas.schema import Catalogue
9
  from app.catalogues.constants import SCM_CATALOGUE_COLLECTION
10
+ from app.core.utils import format_meta_field
11
  from app.dependencies.auth import TokenUser
12
  from app.utils.util import flatten_update_data
13
  from app.catalogues.utils import (
 
228
 
229
  items = []
230
  async for doc in cursor:
231
+ if projection_list:
232
+ # Projection returns raw dict → format directly
233
+ formatted_doc = format_meta_field(doc)
234
+ items.append(formatted_doc)
235
+
236
+ else:
237
+ # Create Pydantic model first
238
+ catalogue_obj = Catalogue(**doc)
239
+
240
+ # Convert to dict
241
+ catalogue_dict = catalogue_obj.model_dump()
242
+
243
+ # Apply formatting AFTER model creation
244
+ formatted_doc = format_meta_field(catalogue_dict)
245
+
246
+ items.append(formatted_doc)
247
 
248
  return items, total_count
249
 
 
367
  inventory=data.inventory,
368
  tax=data.tax,
369
  media=data.media,
370
+ created_by=current_user.user_id,
371
+ created_by_username=current_user.username,
372
+ created_at=datetime.utcnow(),
373
+ updated_at=None,
374
+ updated_by_username=None,
375
+ updated_by=None,
376
+ status="Active",
377
  )
378
 
379
  # Insert into MongoDB
 
400
  detail="Failed to create catalogue item"
401
  )
402
 
403
+ async def update_catalogue_item(self, catalogue_id: str, update_data: dict, updated_by: str, updated_by_username: str, merchant_id: str = None):
404
  """Update an existing catalogue item with merchant filtering."""
405
  try:
406
  # Check if the catalogue exists with merchant filtering
 
455
  update_data_flat = flatten_update_data(update_data)
456
 
457
  # Add metadata
458
+ update_data_flat["updated_by"] = updated_by
459
+ update_data_flat["updated_by_username"] = updated_by_username
460
+ update_data_flat["updated_at"] = datetime.utcnow()
461
 
462
  # Update MongoDB with merchant filtering
463
  update_query = {"catalogue_id": catalogue_id}
 
710
  if projection_list:
711
  return doc
712
  else:
713
+ data = Catalogue(**doc)
714
+ formatted_data = format_meta_field(data.model_dump())
715
+ return formatted_data
716
 
717
  except HTTPException:
718
  raise
 
896
  cr.barcode_number,
897
  cr.hsn_code,
898
  cr.gst_rate,
899
+ cr.updated_at,
900
  cr.mrp,
901
  COALESCE(cp.cost_price, cr.base_price) AS cost_price,
902
  COALESCE(cp.trade_margin, 0) AS trade_margin,
 
1020
  "lead_time_days": int(row.lead_time_days) if row.lead_time_days is not None else 7,
1021
  "max_stock_level": int(row.max_stock_level) if row.max_stock_level is not None else 1000,
1022
  "unit": row.unit if row.unit else "PCS",
1023
+ "updated_at": row.updated_at
1024
  }
1025
+ formatted_item = format_meta_field(row_dict)
1026
  # Apply projection if specified
1027
  if projection_list:
1028
  projected_item = {}
1029
  for field in projection_list:
1030
+ if field in formatted_item:
1031
+ projected_item[field] = formatted_item[field]
1032
  items.append(projected_item)
1033
  else:
1034
+ items.append(formatted_item)
1035
 
1036
  logger.info(f"Retrieved {len(items)} merchant catalogue items for merchant {merchant_id}")
1037
  return items, total_count
app/employees/services/service.py CHANGED
@@ -21,7 +21,7 @@ from app.employees.constants import (
21
  MANAGER_REQUIRED_DESIGNATIONS,
22
  TWO_FA_REQUIRED_DESIGNATIONS,
23
  )
24
- from pydantic import ValidationError as PydanticValidationError
25
  from app.employees.models.model import EmployeeModel
26
  from app.employees.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
27
  from app.system_users.services.service import SystemUserService
@@ -488,7 +488,41 @@ class EmployeeService:
488
 
489
  # Fetch updated employee
490
  updated_employee = await EmployeeService.get_employee_by_id(user_id)
491
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  logger.info(
493
  f"Updated employee {user_id}",
494
  extra={
@@ -499,10 +533,16 @@ class EmployeeService:
499
  )
500
 
501
  formatted_employee = EmployeeService._format_employee_with_meta(updated_employee)
502
- return EmployeeResponse(**formatted_employee)
 
 
 
 
 
 
503
 
504
  except Exception as e:
505
- logger.error(f"Error updating employee {user_id}", exc_info=e)
506
  raise HTTPException(
507
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
508
  detail=f"Error updating employee: {str(e)}"
@@ -840,7 +880,7 @@ class EmployeeService:
840
  @staticmethod
841
  async def _create_employee_system_user(
842
  employee_user_id: str,
843
- employee_payload: EmployeeCreate,
844
  merchant_type: Optional[str] = None
845
  ):
846
  """
@@ -907,14 +947,14 @@ class EmployeeService:
907
  system_user_request = CreateUserRequest(
908
  username=username,
909
  email=employee_payload.email,
910
- merchant_id=employee_payload.created_by,
911
  merchant_type=merchant_type,
912
  password=default_password,
913
  full_name=employee_name,
914
  role_id=role_id,
915
  status=UserStatus.ACTIVE,
916
  metadata={
917
- "employee_user_id": employee_user_id,
918
  "employee_code": employee_payload.employee_code,
919
  "designation": employee_payload.designation,
920
  "created_from": "employee_creation",
 
21
  MANAGER_REQUIRED_DESIGNATIONS,
22
  TWO_FA_REQUIRED_DESIGNATIONS,
23
  )
24
+ from pydantic import TypeAdapter, ValidationError as PydanticValidationError
25
  from app.employees.models.model import EmployeeModel
26
  from app.employees.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
27
  from app.system_users.services.service import SystemUserService
 
488
 
489
  # Fetch updated employee
490
  updated_employee = await EmployeeService.get_employee_by_id(user_id)
491
+ if "app_access" in update_data:
492
+
493
+ app_access = update_data["app_access"]
494
+ is_system_user = app_access.get("is_system_user")
495
+
496
+ system_user_service = SystemUserService(get_database())
497
+
498
+ if is_system_user is True:
499
+
500
+ existing_system_user = await system_user_service.get_user_by_id(user_id)
501
+
502
+ if not existing_system_user:
503
+
504
+ logger.info(f"Creating system user for employee {user_id} from update flow")
505
+
506
+ # Convert DB dict back to schema for reuse
507
+ employee_schema = EmployeeCreate.model_validate(updated_employee)
508
+
509
+ await EmployeeService._create_employee_system_user(
510
+ employee_user_id=user_id,
511
+ employee_payload=employee_schema,
512
+ merchant_type=updated_employee.get("merchant_type"),
513
+ )
514
+
515
+ else:
516
+ logger.info(f"System user already exists for employee {user_id}")
517
+
518
+ elif is_system_user is False:
519
+
520
+ existing_system_user = await system_user_service.get_user_by_id(user_id)
521
+
522
+ if existing_system_user:
523
+ logger.info(f"Deactivating system user for employee {user_id}")
524
+ await system_user_service.deactivate_user(user_id)
525
+
526
  logger.info(
527
  f"Updated employee {user_id}",
528
  extra={
 
533
  )
534
 
535
  formatted_employee = EmployeeService._format_employee_with_meta(updated_employee)
536
+ formatted_employee.pop("_id", None)
537
+ # Convert to JSON-safe dict
538
+ formatted_employee = TypeAdapter(dict).dump_python(
539
+ formatted_employee,
540
+ mode="json"
541
+ )
542
+ return EmployeeResponse.model_validate(formatted_employee)
543
 
544
  except Exception as e:
545
+ logger.exception(f"Error updating employee {user_id}")
546
  raise HTTPException(
547
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
548
  detail=f"Error updating employee: {str(e)}"
 
880
  @staticmethod
881
  async def _create_employee_system_user(
882
  employee_user_id: str,
883
+ employee_payload: dict,
884
  merchant_type: Optional[str] = None
885
  ):
886
  """
 
947
  system_user_request = CreateUserRequest(
948
  username=username,
949
  email=employee_payload.email,
950
+ merchant_id=str(employee_payload.created_by),
951
  merchant_type=merchant_type,
952
  password=default_password,
953
  full_name=employee_name,
954
  role_id=role_id,
955
  status=UserStatus.ACTIVE,
956
  metadata={
957
+ "employee_user_id": str(employee_user_id),
958
  "employee_code": employee_payload.employee_code,
959
  "designation": employee_payload.designation,
960
  "created_from": "employee_creation",
app/inventory/adjustments/controllers/router.py CHANGED
@@ -23,7 +23,7 @@ router = APIRouter(
23
  )
24
 
25
 
26
- @router.post("/", response_model=List[StockAdjustmentResponse], status_code=status.HTTP_201_CREATED)
27
  async def create_stock_adjustment(
28
  payload: CreateStockAdjustmentRequest,
29
  current_user: TokenUser = Depends(get_current_user)
@@ -77,7 +77,8 @@ async def create_stock_adjustment(
77
  return await StockAdjustmentService.create_adjustment(
78
  payload=payload,
79
  merchant_id=current_user.merchant_id,
80
- created_by=current_user.user_id
 
81
  )
82
  except Exception as e:
83
  logger.error(f"Error in create_stock_adjustment endpoint", exc_info=e)
@@ -158,7 +159,7 @@ async def update_adjustment_status(
158
  # Convert to legacy approve request for service compatibility
159
  from app.inventory.adjustments.schemas.schema import ApproveStockAdjustmentRequest
160
  approve_payload = ApproveStockAdjustmentRequest(approved_by=payload.updated_by)
161
- return await StockAdjustmentService.approve_adjustment(adjustment_id, approve_payload)
162
  else: # reject
163
  # Convert to legacy reject request for service compatibility
164
  from app.inventory.adjustments.schemas.schema import RejectStockAdjustmentRequest
 
23
  )
24
 
25
 
26
+ @router.post("/", status_code=status.HTTP_201_CREATED)
27
  async def create_stock_adjustment(
28
  payload: CreateStockAdjustmentRequest,
29
  current_user: TokenUser = Depends(get_current_user)
 
77
  return await StockAdjustmentService.create_adjustment(
78
  payload=payload,
79
  merchant_id=current_user.merchant_id,
80
+ created_by=current_user.user_id,
81
+ current_user=current_user
82
  )
83
  except Exception as e:
84
  logger.error(f"Error in create_stock_adjustment endpoint", exc_info=e)
 
159
  # Convert to legacy approve request for service compatibility
160
  from app.inventory.adjustments.schemas.schema import ApproveStockAdjustmentRequest
161
  approve_payload = ApproveStockAdjustmentRequest(approved_by=payload.updated_by)
162
+ return await StockAdjustmentService.approve_adjustment(adjustment_id, approve_payload, current_user)
163
  else: # reject
164
  # Convert to legacy reject request for service compatibility
165
  from app.inventory.adjustments.schemas.schema import RejectStockAdjustmentRequest
app/inventory/adjustments/schemas/schema.py CHANGED
@@ -92,12 +92,10 @@ class StockAdjustmentResponse(BaseModel):
92
  qty: int
93
  reason: str
94
  status: AdjustmentStatus
95
- created_by: str
96
  approved_by: Optional[str]
97
- created_at: datetime
98
  approved_at: Optional[datetime]
99
  applied_at: Optional[datetime]
100
-
101
 
102
  class StockAdjustmentDetailResponse(BaseModel):
103
  """Response schema for individual adjustment line item"""
 
92
  qty: int
93
  reason: str
94
  status: AdjustmentStatus
 
95
  approved_by: Optional[str]
 
96
  approved_at: Optional[datetime]
97
  applied_at: Optional[datetime]
98
+ meta: Optional[Dict[str, Any]] = None # For any additional metadata
99
 
100
  class StockAdjustmentDetailResponse(BaseModel):
101
  """Response schema for individual adjustment line item"""
app/inventory/adjustments/services/service.py CHANGED
@@ -5,11 +5,13 @@ import uuid
5
  from datetime import datetime
6
  from typing import Optional, List, Dict, Any
7
  from fastapi import HTTPException, status
 
8
  from requests import session
9
  from app.core.logging import get_logger
10
  from sqlalchemy import select, and_, func, text, or_
11
  from decimal import Decimal
12
 
 
13
  from app.sql import async_session
14
  from app.inventory.stock.models.model import (
15
  ScmStockAdjustmentMaster, ScmStockAdjustmentDetails,
@@ -278,8 +280,9 @@ class StockAdjustmentService:
278
  async def create_adjustment(
279
  payload: CreateStockAdjustmentRequest,
280
  merchant_id: Optional[str] = None,
281
- created_by: Optional[str] = None
282
- ) -> List[StockAdjustmentResponse]:
 
283
  """
284
  OPTIMIZED: Create new stock adjustments using master-detail structure with batch operations.
285
 
@@ -317,7 +320,12 @@ class StockAdjustmentService:
317
  status="pending",
318
  total_items=len(payload.entries),
319
  total_adjustment_value=Decimal('0'),
320
- created_by=created_by or "system"
 
 
 
 
 
321
  )
322
 
323
  session.add(master)
@@ -384,23 +392,28 @@ class StockAdjustmentService:
384
 
385
  # Build response objects
386
  for detail in detail_records:
387
- results.append(StockAdjustmentResponse(
388
- adjustment_id=str(detail.adjustment_detail_id),
389
- merchant_id=master.merchant_id,
390
- warehouse_id=master.warehouse_id,
391
- sku=detail.sku,
392
- batch_no=detail.batch_no,
393
- adj_type=AdjustmentType(detail.adj_type),
394
- qty=int(detail.qty),
395
- reason=detail.reason,
396
- status=AdjustmentStatus.PENDING,
397
- created_by=master.created_by,
398
- approved_by=master.approved_by,
399
- created_at=detail.created_at,
400
- approved_at=master.approved_at,
401
- applied_at=master.approved_at
402
- ))
403
-
 
 
 
 
 
404
  # Update master with total value
405
  master.total_adjustment_value = total_value
406
 
@@ -702,22 +715,34 @@ class StockAdjustmentService:
702
  )
703
  master = master_result.scalar_one()
704
 
705
- return StockAdjustmentResponse(
706
- adjustment_id=str(detail.adjustment_detail_id),
707
- merchant_id=master.merchant_id,
708
- warehouse_id=master.warehouse_id,
709
- sku=detail.sku,
710
- batch_no=detail.batch_no,
711
- adj_type=AdjustmentType(detail.adj_type),
712
- qty=int(detail.qty),
713
- reason=detail.reason,
714
- status=AdjustmentStatus(master.status),
715
- created_by=master.created_by,
716
- approved_by=master.approved_by,
717
- created_at=detail.created_at,
718
- approved_at=master.approved_at,
719
- applied_at=master.approved_at
720
- )
 
 
 
 
 
 
 
 
 
 
 
 
721
 
722
  except HTTPException:
723
  raise
@@ -731,7 +756,8 @@ class StockAdjustmentService:
731
  @staticmethod
732
  async def approve_adjustment(
733
  adjustment_id: str,
734
- payload: ApproveStockAdjustmentRequest
 
735
  ) -> StockAdjustmentResponse:
736
  """Approve pending adjustment based on adjustment ID (accepts both master and detail IDs)."""
737
  try:
@@ -805,6 +831,9 @@ class StockAdjustmentService:
805
  master.status = "approved"
806
  master.approved_by = payload.approved_by
807
  master.approved_at = datetime.utcnow()
 
 
 
808
 
809
  # Apply all details
810
  for detail_item in details:
@@ -815,22 +844,34 @@ class StockAdjustmentService:
815
 
816
  await session.commit()
817
 
818
- return StockAdjustmentResponse(
819
- adjustment_id=str(detail.adjustment_detail_id),
820
- merchant_id=master.merchant_id,
821
- warehouse_id=master.warehouse_id,
822
- sku=detail.sku,
823
- batch_no=detail.batch_no,
824
- adj_type=AdjustmentType(detail.adj_type),
825
- qty=int(detail.qty),
826
- reason=detail.reason,
827
- status=AdjustmentStatus(master.status),
828
- created_by=master.created_by,
829
- approved_by=master.approved_by,
830
- created_at=detail.created_at,
831
- approved_at=master.approved_at,
832
- applied_at=master.approved_at
833
- )
 
 
 
 
 
 
 
 
 
 
 
 
834
 
835
  except HTTPException:
836
  raise
@@ -983,7 +1024,11 @@ class StockAdjustmentService:
983
  m.created_by,
984
  m.approved_by,
985
  m.created_at,
986
- m.approved_at
 
 
 
 
987
  FROM trans.scm_stock_adjustment_master m
988
  LEFT JOIN trans.scm_warehouse_ref w ON m.warehouse_id::text = w.warehouse_id::text
989
  WHERE 1=1
@@ -1072,29 +1117,35 @@ class StockAdjustmentService:
1072
  result_list.append(item)
1073
  return result_list, total_count
1074
  else:
 
1075
  # Return full model
1076
- return [
1077
- StockAdjustmentMasterResponse(
1078
- adjustment_master_id=str(row.adjustment_master_id),
1079
- adjustment_number=row.adjustment_number,
1080
- merchant_id=row.merchant_id,
1081
- warehouse_id=row.warehouse_id,
1082
- warehouse_code=row.warehouse_code,
1083
- warehouse_name=row.warehouse_name,
1084
- adjustment_date=row.adjustment_date,
1085
- description=row.description,
1086
- additional_notes=row.additional_notes,
1087
- status=row.status,
1088
- total_items=row.total_items or 0,
1089
- total_value=float(row.total_adjustment_value) if row.total_adjustment_value else 0.0,
1090
- created_by=row.created_by,
1091
- approved_by=row.approved_by,
1092
- created_at=row.created_at,
1093
- approved_at=row.approved_at
1094
- )
1095
- for row in rows
1096
- ], total_count
1097
-
 
 
 
 
 
1098
  except Exception as e:
1099
  logger.error(f"Error listing adjustment masters", exc_info=e)
1100
  raise HTTPException(
 
5
  from datetime import datetime
6
  from typing import Optional, List, Dict, Any
7
  from fastapi import HTTPException, status
8
+ from pymongo import results
9
  from requests import session
10
  from app.core.logging import get_logger
11
  from sqlalchemy import select, and_, func, text, or_
12
  from decimal import Decimal
13
 
14
+ from app.core.utils import format_meta_field
15
  from app.sql import async_session
16
  from app.inventory.stock.models.model import (
17
  ScmStockAdjustmentMaster, ScmStockAdjustmentDetails,
 
280
  async def create_adjustment(
281
  payload: CreateStockAdjustmentRequest,
282
  merchant_id: Optional[str] = None,
283
+ created_by: Optional[str] = None,
284
+ current_user: Optional[Any] = None
285
+ ) :
286
  """
287
  OPTIMIZED: Create new stock adjustments using master-detail structure with batch operations.
288
 
 
320
  status="pending",
321
  total_items=len(payload.entries),
322
  total_adjustment_value=Decimal('0'),
323
+ created_by=created_by or "system",
324
+ updated_by=None,
325
+ updated_by_username=None,
326
+ created_by_username=current_user.username if current_user else "system",
327
+ created_at=datetime.utcnow(),
328
+ updated_at=None
329
  )
330
 
331
  session.add(master)
 
392
 
393
  # Build response objects
394
  for detail in detail_records:
395
+ row_dict = {
396
+ "adjustment_id": str(detail.adjustment_detail_id),
397
+ "merchant_id": master.merchant_id,
398
+ "warehouse_id": master.warehouse_id,
399
+ "sku": detail.sku,
400
+ "batch_no": detail.batch_no,
401
+ "adj_type": AdjustmentType(detail.adj_type),
402
+ "qty": int(detail.qty),
403
+ "reason": detail.reason,
404
+ "status": AdjustmentStatus.PENDING,
405
+
406
+ # Audit fields
407
+ "created_by": master.created_by,
408
+ "created_by_username": master.created_by_username,
409
+ "created_at": detail.created_at,
410
+
411
+ "updated_by": master.updated_by,
412
+ "updated_by_username": master.updated_by_username,
413
+ "updated_at": master.updated_at,
414
+ }
415
+ formatted = format_meta_field(row_dict)
416
+ results.append(formatted)
417
  # Update master with total value
418
  master.total_adjustment_value = total_value
419
 
 
715
  )
716
  master = master_result.scalar_one()
717
 
718
+ row_dict = {
719
+ "adjustment_id": str(detail.adjustment_detail_id),
720
+ "merchant_id": master.merchant_id,
721
+ "warehouse_id": master.warehouse_id,
722
+ "sku": detail.sku,
723
+ "batch_no": detail.batch_no,
724
+ "adj_type": AdjustmentType(detail.adj_type),
725
+ "qty": int(detail.qty),
726
+ "reason": detail.reason,
727
+ "status": AdjustmentStatus(master.status),
728
+
729
+ # Approval workflow (keep outside meta)
730
+ "approved_by": master.approved_by,
731
+ "approved_at": master.approved_at,
732
+ "applied_at": master.applied_at,
733
+ "rejected_at": master.rejected_at,
734
+
735
+ # Audit fields (meta only)
736
+ "created_by": master.created_by,
737
+ "created_by_username": master.created_by_username,
738
+ "created_at": detail.created_at,
739
+ "updated_by": master.updated_by,
740
+ "updated_by_username": master.updated_by_username,
741
+ "updated_at": master.updated_at,
742
+ }
743
+
744
+ formatted = format_meta_field(row_dict)
745
+ return formatted
746
 
747
  except HTTPException:
748
  raise
 
756
  @staticmethod
757
  async def approve_adjustment(
758
  adjustment_id: str,
759
+ payload: ApproveStockAdjustmentRequest,
760
+ current_user:dict
761
  ) -> StockAdjustmentResponse:
762
  """Approve pending adjustment based on adjustment ID (accepts both master and detail IDs)."""
763
  try:
 
831
  master.status = "approved"
832
  master.approved_by = payload.approved_by
833
  master.approved_at = datetime.utcnow()
834
+ master.updated_at = datetime.utcnow()
835
+ master.updated_by = current_user.user_id
836
+ master.updated_by_username = current_user.username
837
 
838
  # Apply all details
839
  for detail_item in details:
 
844
 
845
  await session.commit()
846
 
847
+ row_dict = {
848
+ "adjustment_id": str(detail.adjustment_detail_id),
849
+ "merchant_id": master.merchant_id,
850
+ "warehouse_id": master.warehouse_id,
851
+ "sku": detail.sku,
852
+ "batch_no": detail.batch_no,
853
+ "adj_type": AdjustmentType(detail.adj_type),
854
+ "qty": int(detail.qty),
855
+ "reason": detail.reason,
856
+ "status": AdjustmentStatus(master.status),
857
+
858
+ # Workflow fields (stay outside meta)
859
+ "approved_by": master.approved_by,
860
+ "approved_at": master.approved_at,
861
+ "applied_at": master.applied_at,
862
+
863
+ # Audit fields (go inside meta)
864
+ "created_by": master.created_by,
865
+ "created_by_username": master.created_by_username,
866
+ "created_at": detail.created_at,
867
+ "updated_by": master.updated_by,
868
+ "updated_by_username": master.updated_by_username,
869
+ "updated_at": master.updated_at,
870
+ }
871
+
872
+ formatted = format_meta_field(row_dict)
873
+
874
+ return StockAdjustmentResponse(**formatted)
875
 
876
  except HTTPException:
877
  raise
 
1024
  m.created_by,
1025
  m.approved_by,
1026
  m.created_at,
1027
+ m.approved_at,
1028
+ m.created_by_username,
1029
+ m.updated_at,
1030
+ m.updated_by,
1031
+ m.updated_by_username
1032
  FROM trans.scm_stock_adjustment_master m
1033
  LEFT JOIN trans.scm_warehouse_ref w ON m.warehouse_id::text = w.warehouse_id::text
1034
  WHERE 1=1
 
1117
  result_list.append(item)
1118
  return result_list, total_count
1119
  else:
1120
+ result_list = []
1121
  # Return full model
1122
+ for row in rows:
1123
+ row_dict = {
1124
+ "adjustment_master_id": str(row.adjustment_master_id),
1125
+ "adjustment_number": row.adjustment_number,
1126
+ "merchant_id": row.merchant_id,
1127
+ "warehouse_id": row.warehouse_id,
1128
+ "warehouse_code": row.warehouse_code,
1129
+ "warehouse_name": row.warehouse_name,
1130
+ "adjustment_date": row.adjustment_date,
1131
+ "description": row.description,
1132
+ "additional_notes": row.additional_notes,
1133
+ "status": row.status,
1134
+ "total_items": row.total_items or 0,
1135
+ "total_value": float(row.total_adjustment_value) if row.total_adjustment_value else 0.0,
1136
+ "created_by": row.created_by,
1137
+ "created_at": row.created_at,
1138
+ "approved_by": row.approved_by,
1139
+ "approved_at": row.approved_at,
1140
+ "created_by_username": row.created_by_username,
1141
+ "updated_by_username": row.updated_by_username,
1142
+ "updated_at": row.updated_at,
1143
+ "updated_by": row.updated_by,
1144
+ }
1145
+ print(row_dict)
1146
+ formatted = format_meta_field(row_dict)
1147
+ result_list.append(formatted)
1148
+ return result_list, total_count
1149
  except Exception as e:
1150
  logger.error(f"Error listing adjustment masters", exc_info=e)
1151
  raise HTTPException(
app/inventory/stock/models/model.py CHANGED
@@ -110,6 +110,11 @@ class ScmStockAdjustmentMaster(Base):
110
  approved_at = Column(TIMESTAMP(timezone=True), nullable=True)
111
  rejected_at = Column(TIMESTAMP(timezone=True), nullable=True)
112
  applied_at = Column(TIMESTAMP(timezone=True), nullable=True)
 
 
 
 
 
113
 
114
  # Rejection info
115
  rejection_reason = Column(Text, nullable=True)
 
110
  approved_at = Column(TIMESTAMP(timezone=True), nullable=True)
111
  rejected_at = Column(TIMESTAMP(timezone=True), nullable=True)
112
  applied_at = Column(TIMESTAMP(timezone=True), nullable=True)
113
+ # NEW COLUMNS
114
+ updated_by = Column(String(64), nullable=True)
115
+ updated_by_username = Column(String(100), nullable=True)
116
+ created_by_username = Column(String(100), nullable=True)
117
+ updated_at = Column(TIMESTAMP(timezone=True), nullable=True)
118
 
119
  # Rejection info
120
  rejection_reason = Column(Text, nullable=True)
app/inventory/stock/services/service.py CHANGED
@@ -13,6 +13,7 @@ import json
13
  import logging
14
  from fastapi import HTTPException
15
 
 
16
  from app.purchases.receipts.models.model import ScmGrnItem
17
 
18
  logger = logging.getLogger(__name__)
@@ -443,8 +444,10 @@ class StockService:
443
  base_query = """
444
  SELECT
445
  stock_id, merchant_id, warehouse_id, catalogue_id, sku, batch_no,
446
- exp_dt, qty_on_hand, qty_reserved, qty_available, uom,
447
- created_at, updated_at, last_updated_at
 
 
448
  FROM trans.scm_stock
449
  WHERE 1=1
450
  """
@@ -505,24 +508,27 @@ class StockService:
505
  "qty_available": float(row.qty_available) if row.qty_available else 0,
506
  "uom": row.uom,
507
  "created_at": row.created_at.isoformat() if row.created_at else None,
508
- "updated_at": row.updated_at.isoformat() if row.updated_at else None,
509
- "last_updated_at": row.last_updated_at.isoformat() if row.last_updated_at else None,
 
 
 
510
  }
511
-
512
  # Apply projection if specified
513
  if projection_list:
514
  projected_item = {}
515
  for field in projection_list:
516
- if field in row_dict:
517
- projected_item[field] = row_dict[field]
518
  # Always include essential fields
519
  essential_fields = ["stock_id", "sku"]
520
  for field in essential_fields:
521
- if field not in projected_item and field in row_dict:
522
- projected_item[field] = row_dict[field]
523
  items.append(projected_item)
524
  else:
525
- items.append(row_dict)
526
 
527
  return items, total_count
528
 
@@ -542,9 +548,11 @@ class StockService:
542
  # Build base query
543
  base_query = """
544
  SELECT
545
- ledger_id, merchant_id, warehouse_id, catalogue_id, sku, batch_no,
546
- exp_dt, qty, uom, txn_type, ref_type, ref_id, ref_no,
547
- remarks, created_by, created_at
 
 
548
  FROM trans.scm_stock_ledger
549
  WHERE 1=1
550
  """
@@ -596,35 +604,37 @@ class StockService:
596
  "ledger_id": str(row.ledger_id),
597
  "merchant_id": row.merchant_id,
598
  "warehouse_id": row.warehouse_id,
599
- "catalogue_id": str(row.catalogue_id),
600
  "sku": row.sku,
601
  "batch_no": row.batch_no,
602
- "exp_dt": row.exp_dt.isoformat() if row.exp_dt else None,
603
  "qty": float(row.qty) if row.qty else 0,
604
- "uom": row.uom,
605
  "txn_type": row.txn_type,
606
  "ref_type": row.ref_type,
607
  "ref_id": str(row.ref_id),
608
- "ref_no": row.ref_no,
609
- "remarks": row.remarks,
610
  "created_by": row.created_by,
611
- "created_at": row.created_at.isoformat() if row.created_at else None,
 
 
 
 
612
  }
613
-
614
  # Apply projection if specified
615
  if projection_list:
616
  projected_item = {}
617
  for field in projection_list:
618
- if field in row_dict:
619
- projected_item[field] = row_dict[field]
 
620
  # Always include essential fields
621
  essential_fields = ["ledger_id", "sku", "txn_type"]
622
  for field in essential_fields:
623
- if field not in projected_item and field in row_dict:
624
- projected_item[field] = row_dict[field]
625
  items.append(projected_item)
626
  else:
627
- items.append(row_dict)
628
 
629
  return items, total_count
630
 
 
13
  import logging
14
  from fastapi import HTTPException
15
 
16
+ from app.core.utils import format_meta_field
17
  from app.purchases.receipts.models.model import ScmGrnItem
18
 
19
  logger = logging.getLogger(__name__)
 
444
  base_query = """
445
  SELECT
446
  stock_id, merchant_id, warehouse_id, catalogue_id, sku, batch_no,
447
+ expiry_date as exp_dt, qty_on_hand, qty_reserved, qty_available, uom,
448
+ created_by, created_by_username,
449
+ updated_by, updated_by_username,
450
+ created_at, updated_at
451
  FROM trans.scm_stock
452
  WHERE 1=1
453
  """
 
508
  "qty_available": float(row.qty_available) if row.qty_available else 0,
509
  "uom": row.uom,
510
  "created_at": row.created_at.isoformat() if row.created_at else None,
511
+ "updated_at": row.updated_at.isoformat() if row.updated_at else None,
512
+ "created_by": row.created_by,
513
+ "created_by_username": row.created_by_username,
514
+ "updated_by": row.updated_by,
515
+ "updated_by_username": row.updated_by_username,
516
  }
517
+ formatted = format_meta_field(row_dict)
518
  # Apply projection if specified
519
  if projection_list:
520
  projected_item = {}
521
  for field in projection_list:
522
+ if field in formatted:
523
+ projected_item[field] = formatted[field]
524
  # Always include essential fields
525
  essential_fields = ["stock_id", "sku"]
526
  for field in essential_fields:
527
+ if field not in projected_item and field in formatted:
528
+ projected_item[field] = formatted[field]
529
  items.append(projected_item)
530
  else:
531
+ items.append(formatted)
532
 
533
  return items, total_count
534
 
 
548
  # Build base query
549
  base_query = """
550
  SELECT
551
+ ledger_id, merchant_id, warehouse_id, sku, batch_no,
552
+ qty, txn_type, ref_type, ref_id,
553
+ created_by, created_by_username,
554
+ updated_by, updated_by_username,
555
+ created_at, updated_at
556
  FROM trans.scm_stock_ledger
557
  WHERE 1=1
558
  """
 
604
  "ledger_id": str(row.ledger_id),
605
  "merchant_id": row.merchant_id,
606
  "warehouse_id": row.warehouse_id,
 
607
  "sku": row.sku,
608
  "batch_no": row.batch_no,
 
609
  "qty": float(row.qty) if row.qty else 0,
 
610
  "txn_type": row.txn_type,
611
  "ref_type": row.ref_type,
612
  "ref_id": str(row.ref_id),
613
+
614
+ # Audit fields (DO NOT isoformat here)
615
  "created_by": row.created_by,
616
+ "created_by_username": getattr(row, "created_by_username", None),
617
+ "created_at": row.created_at,
618
+ "updated_by": getattr(row, "updated_by", None),
619
+ "updated_by_username": getattr(row, "updated_by_username", None),
620
+ "updated_at": getattr(row, "updated_at", None),
621
  }
622
+ formatted = format_meta_field(row_dict)
623
  # Apply projection if specified
624
  if projection_list:
625
  projected_item = {}
626
  for field in projection_list:
627
+ if field in formatted:
628
+ projected_item[field] = formatted[field]
629
+ projected_item[field] = formatted[field]
630
  # Always include essential fields
631
  essential_fields = ["ledger_id", "sku", "txn_type"]
632
  for field in essential_fields:
633
+ if field not in projected_item and field in formatted:
634
+ projected_item[field] = formatted[field]
635
  items.append(projected_item)
636
  else:
637
+ items.append(formatted)
638
 
639
  return items, total_count
640
 
app/po_returns/controllers/router.py CHANGED
@@ -70,7 +70,8 @@ async def create_po_return(
70
  po_return, errors = await PoReturnService.create_po_return(
71
  db=db,
72
  return_data=return_data,
73
- created_by=created_by
 
74
  )
75
 
76
  if errors:
@@ -256,7 +257,8 @@ async def handle_po_return_action(
256
  db=db,
257
  po_return_id=po_return_id,
258
  action_request=action_request,
259
- performed_by=performed_by
 
260
  )
261
 
262
  if errors:
 
70
  po_return, errors = await PoReturnService.create_po_return(
71
  db=db,
72
  return_data=return_data,
73
+ created_by=created_by,
74
+ current_user=current_user
75
  )
76
 
77
  if errors:
 
257
  db=db,
258
  po_return_id=po_return_id,
259
  action_request=action_request,
260
+ performed_by=performed_by,
261
+ current_user=current_user
262
  )
263
 
264
  if errors:
app/po_returns/models/model.py CHANGED
@@ -57,9 +57,24 @@ class PoReturn(Base):
57
  remarks = Column(Text)
58
 
59
  # Audit fields
60
- created_by = Column(String(64))
61
- created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
62
- updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  # Relationships
65
  items = relationship("PoReturnItem", back_populates="po_return", cascade="all, delete-orphan")
 
57
  remarks = Column(Text)
58
 
59
  # Audit fields
60
+ created_by = Column(String(64), nullable=False)
61
+ created_by_username = Column(String(100), nullable=True)
62
+
63
+ created_at = Column(
64
+ DateTime,
65
+ nullable=False,
66
+ default=datetime.utcnow
67
+ )
68
+
69
+ updated_by = Column(String(64), nullable=True)
70
+ updated_by_username = Column(String(100), nullable=True)
71
+
72
+ updated_at = Column(
73
+ DateTime,
74
+ nullable=False,
75
+ default=datetime.utcnow,
76
+ onupdate=datetime.utcnow
77
+ )
78
 
79
  # Relationships
80
  items = relationship("PoReturnItem", back_populates="po_return", cascade="all, delete-orphan")
app/po_returns/schemas/schema.py CHANGED
@@ -157,14 +157,10 @@ class PoReturnRead(BaseModel):
157
  return_date: datetime
158
  status: str
159
  reason_code: Optional[str]
160
- remarks: Optional[str]
161
- created_by: Optional[str]
162
- created_at: datetime
163
- updated_at: datetime
164
-
165
  # Optional related data
166
  items: Optional[List[PoReturnItemRead]] = None
167
-
168
  class Config:
169
  from_attributes = True
170
 
 
157
  return_date: datetime
158
  status: str
159
  reason_code: Optional[str]
160
+ remarks: Optional[str]
 
 
 
 
161
  # Optional related data
162
  items: Optional[List[PoReturnItemRead]] = None
163
+ meta: Optional[Dict[str, Any]] = None # For any additional info like approval history, etc.
164
  class Config:
165
  from_attributes = True
166
 
app/po_returns/services/service.py CHANGED
@@ -13,6 +13,7 @@ from sqlalchemy import and_, or_, func, select, text
13
  from sqlalchemy.ext.asyncio import AsyncSession
14
  from sqlalchemy.orm import selectinload
15
 
 
16
  from app.po_returns.models.model import PoReturn, PoReturnItem, ReturnReasonCode
17
  from app.po_returns.schemas.schema import (
18
  PoReturnCreate, PoReturnRead, PoReturnActionRequest,
@@ -260,7 +261,8 @@ class PoReturnService:
260
  async def create_po_return(
261
  db: AsyncSession,
262
  return_data: PoReturnCreate,
263
- created_by: str
 
264
  ) -> Tuple[Optional[PoReturn], List[ValidationError]]:
265
  """Create a PO Return"""
266
  errors = []
@@ -295,11 +297,22 @@ class PoReturnService:
295
  supplier_id=return_data.supplier_id,
296
  client_id=return_data.buyer_id,
297
  warehouse_id=return_data.warehouse_id,
298
- return_date=datetime.combine(return_data.po_return_date, datetime.min.time()),
 
 
 
299
  status="DRAFT",
300
  reason_code=return_data.reason_code.value,
301
  remarks=return_data.remarks,
302
- created_by=created_by
 
 
 
 
 
 
 
 
303
  )
304
 
305
  db.add(po_return)
@@ -458,8 +471,51 @@ class PoReturnService:
458
  po_return = result.scalar_one_or_none()
459
 
460
  if po_return:
461
- return PoReturnRead.from_orm(po_return)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  return None
 
463
 
464
  except Exception as e:
465
  logger.error(f"Error getting PO return {po_return_id}: {e}")
@@ -470,7 +526,8 @@ class PoReturnService:
470
  db: AsyncSession,
471
  po_return_id: UUID,
472
  action_request: PoReturnActionRequest,
473
- performed_by: str
 
474
  ) -> Tuple[Optional[PoReturn], List[ValidationError]]:
475
  """Handle all PO return actions in a single method"""
476
  errors = []
@@ -537,6 +594,8 @@ class PoReturnService:
537
  po_return.remarks = action_request.remarks
538
 
539
  po_return.updated_at = datetime.utcnow()
 
 
540
 
541
  elif action == "complete":
542
  # Complete action (from separate method)
@@ -656,8 +715,53 @@ class PoReturnService:
656
  # Execute query
657
  result = await db.execute(query)
658
  po_returns = result.scalars().all()
659
-
660
- return [PoReturnRead.from_orm(ret) for ret in po_returns]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
 
662
  except Exception as e:
663
  logger.error(f"Error listing PO returns: {e}")
 
13
  from sqlalchemy.ext.asyncio import AsyncSession
14
  from sqlalchemy.orm import selectinload
15
 
16
+ from app.core.utils import format_meta_field
17
  from app.po_returns.models.model import PoReturn, PoReturnItem, ReturnReasonCode
18
  from app.po_returns.schemas.schema import (
19
  PoReturnCreate, PoReturnRead, PoReturnActionRequest,
 
261
  async def create_po_return(
262
  db: AsyncSession,
263
  return_data: PoReturnCreate,
264
+ created_by: str,
265
+ current_user: Optional[Dict[str, Any]] = None
266
  ) -> Tuple[Optional[PoReturn], List[ValidationError]]:
267
  """Create a PO Return"""
268
  errors = []
 
297
  supplier_id=return_data.supplier_id,
298
  client_id=return_data.buyer_id,
299
  warehouse_id=return_data.warehouse_id,
300
+ return_date=datetime.combine(
301
+ return_data.po_return_date,
302
+ datetime.min.time()
303
+ ),
304
  status="DRAFT",
305
  reason_code=return_data.reason_code.value,
306
  remarks=return_data.remarks,
307
+
308
+ # ✅ Audit fields
309
+ created_by=created_by,
310
+ created_by_username=current_user.username if current_user else None,
311
+ created_at=datetime.utcnow(),
312
+
313
+ updated_by=None,
314
+ updated_by_username=None,
315
+ updated_at=None,
316
  )
317
 
318
  db.add(po_return)
 
471
  po_return = result.scalar_one_or_none()
472
 
473
  if po_return:
474
+ po_dict = {
475
+ "po_return_id": str(po_return.po_return_id),
476
+ "po_return_no": po_return.po_return_no,
477
+ "po_id": str(po_return.po_id),
478
+ "supplier_id": po_return.supplier_id,
479
+ "client_id": po_return.client_id,
480
+ "warehouse_id": po_return.warehouse_id,
481
+ "return_date": po_return.return_date,
482
+ "status": po_return.status,
483
+ "reason_code": po_return.reason_code,
484
+ "remarks": po_return.remarks,
485
+
486
+ # Audit fields
487
+ "created_by": po_return.created_by,
488
+ "created_by_username": po_return.created_by_username,
489
+ "created_at": po_return.created_at,
490
+ "updated_by": po_return.updated_by,
491
+ "updated_by_username": po_return.updated_by_username,
492
+ "updated_at": po_return.updated_at,
493
+ }
494
+
495
+ # Include items if requested
496
+ if include_items and po_return.items:
497
+ po_dict["items"] = [
498
+ {
499
+ "po_return_item_id": str(item.po_return_item_id),
500
+ "po_item_id": str(item.po_item_id),
501
+ "catalogue_id": str(item.catalogue_id),
502
+ "batch_no": item.batch_no,
503
+ "expiry_date": item.expiry_date,
504
+ "return_qty": float(item.return_qty),
505
+ "uom": item.uom,
506
+ "cost_price": float(item.cost_price),
507
+ "return_value": float(item.return_value) if item.return_value else 0,
508
+ "created_at": item.created_at,
509
+ }
510
+ for item in po_return.items
511
+ ]
512
+
513
+ formatted = format_meta_field(po_dict)
514
+
515
+ return formatted
516
+
517
  return None
518
+
519
 
520
  except Exception as e:
521
  logger.error(f"Error getting PO return {po_return_id}: {e}")
 
526
  db: AsyncSession,
527
  po_return_id: UUID,
528
  action_request: PoReturnActionRequest,
529
+ performed_by: str,
530
+ current_user: Optional[Dict[str, Any]] = None
531
  ) -> Tuple[Optional[PoReturn], List[ValidationError]]:
532
  """Handle all PO return actions in a single method"""
533
  errors = []
 
594
  po_return.remarks = action_request.remarks
595
 
596
  po_return.updated_at = datetime.utcnow()
597
+ po_return.updated_by = performed_by
598
+ po_return.updated_by_username = current_user.username if current_user else None
599
 
600
  elif action == "complete":
601
  # Complete action (from separate method)
 
715
  # Execute query
716
  result = await db.execute(query)
717
  po_returns = result.scalars().all()
718
+
719
+ response_list = []
720
+
721
+ for ret in po_returns:
722
+ po_dict = {
723
+ "po_return_id": str(ret.po_return_id),
724
+ "po_return_no": ret.po_return_no,
725
+ "po_id": str(ret.po_id),
726
+ "supplier_id": ret.supplier_id,
727
+ "client_id": ret.client_id,
728
+ "warehouse_id": ret.warehouse_id,
729
+ "return_date": ret.return_date,
730
+ "status": ret.status,
731
+ "reason_code": ret.reason_code,
732
+ "remarks": ret.remarks,
733
+
734
+ # Audit fields
735
+ "created_by": ret.created_by,
736
+ "created_by_username": ret.created_by_username,
737
+ "created_at": ret.created_at,
738
+ "updated_by": ret.updated_by,
739
+ "updated_by_username": ret.updated_by_username,
740
+ "updated_at": ret.updated_at,
741
+ }
742
+
743
+ # Include items (already eager loaded)
744
+ if ret.items:
745
+ po_dict["items"] = [
746
+ {
747
+ "po_return_item_id": str(item.po_return_item_id),
748
+ "po_item_id": str(item.po_item_id),
749
+ "catalogue_id": str(item.catalogue_id),
750
+ "batch_no": item.batch_no,
751
+ "expiry_date": item.expiry_date,
752
+ "return_qty": float(item.return_qty),
753
+ "uom": item.uom,
754
+ "cost_price": float(item.cost_price),
755
+ "return_value": float(item.return_value) if item.return_value else 0,
756
+ "created_at": item.created_at,
757
+ }
758
+ for item in ret.items
759
+ ]
760
+
761
+ formatted = format_meta_field(po_dict)
762
+ response_list.append(formatted)
763
+
764
+ return response_list
765
 
766
  except Exception as e:
767
  logger.error(f"Error listing PO returns: {e}")
app/purchases/orders/controllers/router.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import time
2
  from typing import Any, Dict
3
  from fastapi import APIRouter, Depends, HTTPException, Path
@@ -64,6 +65,7 @@ async def create_po(
64
  po_data['buyer_id'] = current_user.merchant_id
65
  po_data['buyer_type'] = current_user.merchant_type
66
  po_data['created_by'] = current_user.user_id
 
67
 
68
  # Create new POCreate instance with JWT data
69
  updated_payload = POCreate(**po_data)
@@ -302,6 +304,9 @@ async def update_po(
302
 
303
  # Convert po_data to POUpdate model
304
  from app.purchases.orders.schemas.schema import POUpdate
 
 
 
305
  update_data = POUpdate(**payload.po_data)
306
  po = await service.update_po(po_id, update_data, merchant_id=current_user.merchant_id)
307
  message = f"Purchase Order {po['po_no']} updated successfully"
 
1
+ import datetime
2
  import time
3
  from typing import Any, Dict
4
  from fastapi import APIRouter, Depends, HTTPException, Path
 
65
  po_data['buyer_id'] = current_user.merchant_id
66
  po_data['buyer_type'] = current_user.merchant_type
67
  po_data['created_by'] = current_user.user_id
68
+ po_data['created_by_username'] = current_user.username
69
 
70
  # Create new POCreate instance with JWT data
71
  updated_payload = POCreate(**po_data)
 
304
 
305
  # Convert po_data to POUpdate model
306
  from app.purchases.orders.schemas.schema import POUpdate
307
+ po.updated_by = current_user.user_id
308
+ po.updated_by_username = current_user.username
309
+ po.updated_at = datetime.utcnow()
310
  update_data = POUpdate(**payload.po_data)
311
  po = await service.update_po(po_id, update_data, merchant_id=current_user.merchant_id)
312
  message = f"Purchase Order {po['po_no']} updated successfully"
app/purchases/orders/models/model.py CHANGED
@@ -44,7 +44,13 @@ class ScmPo(Base):
44
  created_by = Column(String(64), nullable=False)
45
  created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow)
46
  updated_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
47
-
 
 
 
 
 
 
48
  # Relationships
49
  items = relationship("ScmPoItem", back_populates="purchase_order", cascade="all, delete-orphan")
50
  status_logs = relationship("ScmPoStatusLog", back_populates="purchase_order", cascade="all, delete-orphan")
 
44
  created_by = Column(String(64), nullable=False)
45
  created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow)
46
  updated_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
47
+ # Audit fields
48
+ created_by = Column(String(64), nullable=False)
49
+ created_by_username = Column(String(100), nullable=True)
50
+
51
+ updated_by = Column(String(64), nullable=True)
52
+ updated_by_username = Column(String(100), nullable=True)
53
+
54
  # Relationships
55
  items = relationship("ScmPoItem", back_populates="purchase_order", cascade="all, delete-orphan")
56
  status_logs = relationship("ScmPoStatusLog", back_populates="purchase_order", cascade="all, delete-orphan")
app/purchases/orders/schemas/schema.py CHANGED
@@ -58,12 +58,16 @@ class POCreate(BaseModel):
58
 
59
  # created_by will be extracted from JWT token
60
  created_by: Optional[str] = Field(None, description="User who created the PO (extracted from JWT)")
 
61
 
62
 
63
  class POUpdate(BaseModel):
64
  exp_delivery_dt: Optional[date] = Field(None, description="Updated expected delivery date")
65
  remarks: Optional[str] = Field(None, description="Updated remarks")
66
  items: Optional[List[POItemUpdate]] = Field(None, description="Updated items")
 
 
 
67
 
68
 
69
  class POItemRead(BaseModel):
@@ -124,9 +128,7 @@ class PORead(BaseModel):
124
  net_amt: Decimal
125
  status: str
126
  remarks: Optional[str]
127
- created_by: str
128
- created_at: datetime
129
- updated_at: datetime
130
  items: Optional[List[POItemRead]] = None
131
  status_logs: Optional[List[POStatusLogRead]] = None
132
 
 
58
 
59
  # created_by will be extracted from JWT token
60
  created_by: Optional[str] = Field(None, description="User who created the PO (extracted from JWT)")
61
+ created_by_username: Optional[str] = Field(None, description="Username of the user who created the PO (extracted from JWT)")
62
 
63
 
64
  class POUpdate(BaseModel):
65
  exp_delivery_dt: Optional[date] = Field(None, description="Updated expected delivery date")
66
  remarks: Optional[str] = Field(None, description="Updated remarks")
67
  items: Optional[List[POItemUpdate]] = Field(None, description="Updated items")
68
+ updated_by: Optional[str] = Field(None, description="User who updated the PO (extracted from JWT)")
69
+ updated_by_username: Optional[str] = Field(None, description="Username of the user who updated the PO (extracted from JWT)")
70
+ updated_at: Optional[datetime] = Field(None, description="Timestamp of when the PO was updated (set to current time)")
71
 
72
 
73
  class POItemRead(BaseModel):
 
128
  net_amt: Decimal
129
  status: str
130
  remarks: Optional[str]
131
+ meta: Optional[Dict[str, Any]] = Field(None, description="HATEOAS links for related resources and actions")
 
 
132
  items: Optional[List[POItemRead]] = None
133
  status_logs: Optional[List[POStatusLogRead]] = None
134
 
app/purchases/orders/services/service.py CHANGED
@@ -7,6 +7,7 @@ from decimal import Decimal
7
  from datetime import datetime
8
  import logging
9
 
 
10
  from app.purchases.orders.models.model import ScmPo, ScmPoItem, ScmPoStatusLog
11
  from app.purchases.orders.schemas.schema import POCreate, POUpdate, POStatusChange
12
  from app.purchases.utils import get_next_po_number, initialize_sequences
@@ -77,6 +78,7 @@ class OrdersService:
77
  status="draft",
78
  remarks=po_in.remarks,
79
  created_by=po_in.created_by,
 
80
  total_amt=Decimal(0), # Will be updated after items
81
  tax_amt=tax_amt,
82
  net_amt=Decimal(0) # Will be updated after items
@@ -151,7 +153,7 @@ class OrdersService:
151
  SELECT
152
  p.po_id, p.po_no, p.buyer_id, p.buyer_type, p.supplier_id, p.supplier_type,
153
  p.po_date, p.exp_delivery_dt, p.currency, p.total_amt, p.tax_amt, p.net_amt,
154
- p.status, p.remarks, p.created_by, p.created_at, p.updated_at,
155
  buyer_ref.merchant_name as buyer_name,
156
  supplier_ref.merchant_name as supplier_name
157
  FROM trans.scm_po p
@@ -193,8 +195,13 @@ class OrdersService:
193
  "net_amt": float(po_row.net_amt) if po_row.net_amt else 0,
194
  "status": po_row.status,
195
  "remarks": po_row.remarks,
 
 
196
  "created_by": po_row.created_by,
 
197
  "created_at": po_row.created_at,
 
 
198
  "updated_at": po_row.updated_at
199
  }
200
 
@@ -276,8 +283,8 @@ class OrdersService:
276
  }
277
  for log in logs
278
  ]
279
-
280
- return po_dict
281
 
282
  async def list_pos(
283
  self,
@@ -297,7 +304,7 @@ class OrdersService:
297
  SELECT
298
  p.po_id, p.po_no, p.buyer_id, p.buyer_type, p.supplier_id, p.supplier_type,
299
  p.po_date, p.exp_delivery_dt, p.currency, p.total_amt, p.tax_amt, p.net_amt,
300
- p.status, p.remarks, p.created_by, p.created_at, p.updated_at,
301
  buyer_ref.merchant_name as buyer_name,
302
  supplier_ref.merchant_name as supplier_name,
303
  EXISTS(SELECT 1 FROM trans.scm_grn g WHERE g.po_id = p.po_id) as has_grn
@@ -415,18 +422,24 @@ class OrdersService:
415
  "net_amt": float(row.net_amt) if row.net_amt else 0,
416
  "status": row.status,
417
  "remarks": row.remarks,
 
418
  "created_by": row.created_by,
 
 
 
 
 
419
  "created_at": row.created_at.isoformat() if row.created_at else None,
420
  "updated_at": row.updated_at.isoformat() if row.updated_at else None,
421
  "has_grn": row.has_grn
422
  }
423
-
424
  # Apply projection if specified
425
  if projection_list:
426
  projected_dict = {}
427
  for field in projection_list:
428
- if field in po_dict:
429
- projected_dict[field] = po_dict[field]
430
  # Always include essential fields
431
  essential_fields = ["po_id", "po_no"]
432
  for field in essential_fields:
@@ -434,7 +447,7 @@ class OrdersService:
434
  projected_dict[field] = po_dict[field]
435
  po_dicts.append(projected_dict)
436
  else:
437
- po_dicts.append(po_dict)
438
 
439
  return po_dicts, total_count
440
 
 
7
  from datetime import datetime
8
  import logging
9
 
10
+ from app.core.utils import format_meta_field
11
  from app.purchases.orders.models.model import ScmPo, ScmPoItem, ScmPoStatusLog
12
  from app.purchases.orders.schemas.schema import POCreate, POUpdate, POStatusChange
13
  from app.purchases.utils import get_next_po_number, initialize_sequences
 
78
  status="draft",
79
  remarks=po_in.remarks,
80
  created_by=po_in.created_by,
81
+ created_by_username=po_in.created_by_username,
82
  total_amt=Decimal(0), # Will be updated after items
83
  tax_amt=tax_amt,
84
  net_amt=Decimal(0) # Will be updated after items
 
153
  SELECT
154
  p.po_id, p.po_no, p.buyer_id, p.buyer_type, p.supplier_id, p.supplier_type,
155
  p.po_date, p.exp_delivery_dt, p.currency, p.total_amt, p.tax_amt, p.net_amt,
156
+ p.status, p.remarks, p.created_by, p.created_by_username, p.created_at, p.updated_by, p.updated_by_username, p.updated_at,
157
  buyer_ref.merchant_name as buyer_name,
158
  supplier_ref.merchant_name as supplier_name
159
  FROM trans.scm_po p
 
195
  "net_amt": float(po_row.net_amt) if po_row.net_amt else 0,
196
  "status": po_row.status,
197
  "remarks": po_row.remarks,
198
+
199
+ # Audit fields
200
  "created_by": po_row.created_by,
201
+ "created_by_username": getattr(po_row, "created_by_username", None),
202
  "created_at": po_row.created_at,
203
+ "updated_by": getattr(po_row, "updated_by", None),
204
+ "updated_by_username": getattr(po_row, "updated_by_username", None),
205
  "updated_at": po_row.updated_at
206
  }
207
 
 
283
  }
284
  for log in logs
285
  ]
286
+ formatted = format_meta_field(po_dict)
287
+ return formatted
288
 
289
  async def list_pos(
290
  self,
 
304
  SELECT
305
  p.po_id, p.po_no, p.buyer_id, p.buyer_type, p.supplier_id, p.supplier_type,
306
  p.po_date, p.exp_delivery_dt, p.currency, p.total_amt, p.tax_amt, p.net_amt,
307
+ p.status, p.remarks, p.created_by, p.created_at, p.updated_at,p.updated_by, p.updated_by_username,p.created_by_username,
308
  buyer_ref.merchant_name as buyer_name,
309
  supplier_ref.merchant_name as supplier_name,
310
  EXISTS(SELECT 1 FROM trans.scm_grn g WHERE g.po_id = p.po_id) as has_grn
 
422
  "net_amt": float(row.net_amt) if row.net_amt else 0,
423
  "status": row.status,
424
  "remarks": row.remarks,
425
+ # Audit fields (DO NOT isoformat here)
426
  "created_by": row.created_by,
427
+ "created_by_username": getattr(row, "created_by_username", None),
428
+ "created_at": row.created_at,
429
+ "updated_by": getattr(row, "updated_by", None),
430
+ "updated_by_username": getattr(row, "updated_by_username", None),
431
+ "updated_at": row.updated_at,
432
  "created_at": row.created_at.isoformat() if row.created_at else None,
433
  "updated_at": row.updated_at.isoformat() if row.updated_at else None,
434
  "has_grn": row.has_grn
435
  }
436
+ formatted = format_meta_field(po_dict)
437
  # Apply projection if specified
438
  if projection_list:
439
  projected_dict = {}
440
  for field in projection_list:
441
+ if field in formatted:
442
+ projected_dict[field] = formatted[field]
443
  # Always include essential fields
444
  essential_fields = ["po_id", "po_no"]
445
  for field in essential_fields:
 
447
  projected_dict[field] = po_dict[field]
448
  po_dicts.append(projected_dict)
449
  else:
450
+ po_dicts.append(formatted)
451
 
452
  return po_dicts, total_count
453
 
app/purchases/receipts/controllers/router.py CHANGED
@@ -43,16 +43,16 @@ async def create_grn(
43
  # Auto-set merchant context from JWT
44
  payload.receiver_id = current_user.merchant_id
45
  payload.created_by = current_user.user_id
46
-
47
  service = ReceiptsService(db)
48
  grn = await service.create_grn(payload)
49
 
50
  logger.info(
51
  "GRN created successfully",
52
  extra={
53
- "grn_id": str(grn.grn_id),
54
- "grn_no": grn.grn_no,
55
- "po_id": str(grn.po_id),
56
  "created_by": current_user.user_id,
57
  "duration": time.time() - start_time
58
  }
@@ -68,7 +68,7 @@ async def create_grn(
68
  raise HTTPException(status_code=500, detail="Internal server error")
69
 
70
 
71
- @router.get("/{grn_id}", response_model=GRNRead)
72
  async def get_grn(
73
  grn_id: str = Path(..., description="GRN ID"),
74
  current_user: TokenUser = Depends(get_current_user),
 
43
  # Auto-set merchant context from JWT
44
  payload.receiver_id = current_user.merchant_id
45
  payload.created_by = current_user.user_id
46
+ payload.created_by_username = current_user.username # Optional: include username for better audit trails
47
  service = ReceiptsService(db)
48
  grn = await service.create_grn(payload)
49
 
50
  logger.info(
51
  "GRN created successfully",
52
  extra={
53
+ "grn_id": str(grn["grn_id"]), # Changed from grn.grn_id to grn["grn_id"]
54
+ "grn_no": grn["grn_no"], # Changed from grn.grn_no to grn["grn_no"]
55
+ "po_id": str(grn["po_id"]) if grn.get("po_id") else None, # Changed from grn.po_id to grn["po_id"]
56
  "created_by": current_user.user_id,
57
  "duration": time.time() - start_time
58
  }
 
68
  raise HTTPException(status_code=500, detail="Internal server error")
69
 
70
 
71
+ @router.get("/{grn_id}")
72
  async def get_grn(
73
  grn_id: str = Path(..., description="GRN ID"),
74
  current_user: TokenUser = Depends(get_current_user),
app/purchases/receipts/models/model.py CHANGED
@@ -41,8 +41,11 @@ class ScmGrn(Base):
41
  remarks = Column(Text)
42
 
43
  # Audit
44
- created_by = Column(String(64))
45
- created_at = Column(TIMESTAMP(timezone=True), default=datetime.utcnow)
 
 
 
46
 
47
  # Relationships
48
  purchase_order = relationship("ScmPo", back_populates="grns")
 
41
  remarks = Column(Text)
42
 
43
  # Audit
44
+ created_by = Column(String(64), nullable=False)
45
+ created_by_username = Column(String(100), nullable=True)
46
+ created_at = Column(TIMESTAMP(timezone=True), nullable=False, default=datetime.utcnow)
47
+ updated_by = Column(String(64), nullable=True)
48
+ updated_by_username = Column(String(100), nullable=True)
49
 
50
  # Relationships
51
  purchase_order = relationship("ScmPo", back_populates="grns")
app/purchases/receipts/schemas/schema.py CHANGED
@@ -58,7 +58,7 @@ class GRNCreate(BaseModel):
58
  shipment_id : str = Field(..., description="shipment identifier")
59
  received_by : str = Field(..., description="Person who received the goods")
60
  transporter : str = Field(..., description="Transporter details")
61
-
62
  class GRNItemRead(BaseModel):
63
  grn_item_id: UUID
64
  grn_id: UUID
@@ -109,6 +109,7 @@ class GRNRead(BaseModel):
109
  received_by : Optional[str]
110
  transporter : Optional[str]
111
  items: Optional[List[GRNItemRead]] = None
 
112
 
113
  class Config:
114
  from_attributes = True
 
58
  shipment_id : str = Field(..., description="shipment identifier")
59
  received_by : str = Field(..., description="Person who received the goods")
60
  transporter : str = Field(..., description="Transporter details")
61
+ created_by_username: Optional[str] = Field(None, description="Username of the creator (auto-filled from JWT)")
62
  class GRNItemRead(BaseModel):
63
  grn_item_id: UUID
64
  grn_id: UUID
 
109
  received_by : Optional[str]
110
  transporter : Optional[str]
111
  items: Optional[List[GRNItemRead]] = None
112
+ meta: Optional[Dict[str, Any]] = None # For any additional dynamic fields
113
 
114
  class Config:
115
  from_attributes = True
app/purchases/receipts/services/service.py CHANGED
@@ -7,6 +7,7 @@ from decimal import Decimal
7
  from datetime import datetime
8
  import logging
9
 
 
10
  from app.purchases.receipts.models.model import ScmGrn, ScmGrnItem, ScmGrnIssue
11
  from app.purchases.orders.models.model import ScmPo, ScmPoItem
12
  from app.purchases.receipts.schemas.schema import GRNCreate, GRNStatusChange, GRNIssueRequest
@@ -34,7 +35,7 @@ class ReceiptsService:
34
  # TODO: wire taxonomy conversion once available
35
  return qty, uom
36
 
37
- async def create_grn(self, grn_in: GRNCreate) -> ScmGrn:
38
  """Create a new Goods Receipt Note"""
39
 
40
  po = None
@@ -113,7 +114,8 @@ class ReceiptsService:
113
  created_by=grn_in.created_by,
114
  shipment_id=grn_in.shipment_id,
115
  received_by=grn_in.received_by,
116
- transporter=grn_in.transporter
 
117
  )
118
  self.db.add(grn)
119
 
@@ -175,15 +177,68 @@ class ReceiptsService:
175
  logger.info(f"Created GRN {grn.grn_no} for PO {po.po_no} with {len(grn_in.items)} items")
176
  return grn_with_relations
177
 
178
- async def get_grn(self, grn_id: str, include_items: bool = True) -> Optional[ScmGrn]:
179
- """Get GRN by ID with optional items"""
180
- query = select(ScmGrn).where(ScmGrn.grn_id == grn_id)
 
 
 
 
 
 
 
 
 
181
 
 
 
 
 
 
 
 
 
 
 
 
182
  if include_items:
183
  query = query.options(selectinload(ScmGrn.items))
184
-
185
  result = await self.db.execute(query)
186
- return result.scalar_one_or_none()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
  async def list_grns(
189
  self,
@@ -213,7 +268,13 @@ class ReceiptsService:
213
  g.status,
214
  g.total_qty,
215
  g.remarks,
216
- COALESCE(emp.full_name, g.created_by) AS received_by_name
 
 
 
 
 
 
217
  FROM trans.scm_grn g
218
  LEFT JOIN trans.scm_po po ON g.po_id = po.po_id
219
  LEFT JOIN trans.merchants_ref sup ON g.supplier_id = sup.merchant_id
@@ -306,12 +367,14 @@ class ReceiptsService:
306
 
307
  # Full response
308
  full_list: List[Dict[str, Any]] = []
 
309
  for row in rows:
310
- full_list.append({
311
-
312
  "grn_no": row.grn_no,
 
313
  "po_no": row.po_no,
314
- "receiver_code": row.receiver_code,
315
  "supplier_code": row.supplier_code,
316
  "supplier_name": row.supplier_name,
317
  "warehouse_name": row.warehouse_name,
@@ -320,8 +383,13 @@ class ReceiptsService:
320
  "total_qty": _serialize(row.total_qty),
321
  "remarks": row.remarks,
322
  "received_by_name": row.received_by_name,
323
-
324
- })
 
 
 
 
 
325
  return full_list, total_count
326
 
327
  async def _change_status(
@@ -333,8 +401,20 @@ class ReceiptsService:
333
  ) -> ScmGrn:
334
  """Change GRN status with validation"""
335
 
336
- # Get current GRN
337
- grn = await self.get_grn(grn_id, include_items=False)
 
 
 
 
 
 
 
 
 
 
 
 
338
  if not grn:
339
  raise ValueError("GRN not found")
340
 
@@ -362,12 +442,15 @@ class ReceiptsService:
362
  async def accept_grn(self, grn_id: str, status_change: GRNStatusChange) -> ScmGrn:
363
  """Accept GRN - goods are accepted into inventory and update PO status"""
364
  # First, get the GRN with items to check PO relationship
365
- grn = await self.get_grn(grn_id, include_items=True)
366
- if not grn:
367
  raise ValueError("GRN not found")
368
 
369
- # Change GRN status to accepted
370
- grn = await self._change_status(
 
 
 
371
  grn_id, "accepted", status_change.changed_by, status_change.remarks
372
  )
373
 
@@ -376,26 +459,26 @@ class ReceiptsService:
376
  from app.inventory.stock.services.service import StockService
377
  stock_service = StockService(self.db)
378
 
379
- # Process GRN completion to update stock
380
  stock_results = await stock_service.process_grn_completion(
381
- grn_id=str(grn_id),
382
  completed_by=status_change.changed_by
383
  )
384
 
385
- logger.info(f"GRN {grn.grn_no} stock movements processed: {len(stock_results)} entries created")
386
 
387
  # Check if all stock transactions succeeded
388
  failed_transactions = [r for r in stock_results if not r[1]]
389
  if failed_transactions:
390
- logger.error(f"Some stock transactions failed for GRN {grn.grn_no}: {failed_transactions}")
391
  # Continue with PO processing even if stock fails (can be retried)
392
 
393
  except Exception as e:
394
- logger.error(f"Error processing stock movements for GRN {grn.grn_no}: {e}", exc_info=True)
395
  # Don't fail the GRN acceptance if stock processing fails (can be retried)
396
 
397
  # If GRN is linked to a PO, update PO received quantities and check for auto-close
398
- if grn.po_id:
399
  try:
400
  # Import here to avoid circular imports
401
  from app.purchases.orders.services.service import OrdersService
@@ -403,14 +486,14 @@ class ReceiptsService:
403
 
404
  # Prepare GRN items data for PO update
405
  grn_items_data = []
406
- for item in grn.items:
407
- if item.po_item_id: # Only process items linked to PO
408
  grn_items_data.append({
409
- 'po_item_id': item.po_item_id,
410
- 'acc_qty': item.acc_qty,
411
- 'acc_ord_uom_qty': item.ord_uom_qty or item.acc_qty,
412
- 'acc_ord_uom': item.ord_uom or item.uom,
413
- 'sku': item.sku
414
  })
415
 
416
  if grn_items_data:
@@ -418,29 +501,29 @@ class ReceiptsService:
418
  update_success = await po_service.update_po_received_quantities(grn_items_data)
419
 
420
  if update_success:
421
- logger.info(f"Updated PO received quantities for GRN {grn.grn_no}")
422
 
423
  # Check if PO should be auto-closed
424
  closed_po = await po_service.check_and_auto_close_po(
425
- str(grn.po_id),
426
  status_change.changed_by
427
  )
428
 
429
  if closed_po:
430
  logger.info(
431
- f"PO {closed_po.po_no} auto-closed after accepting GRN {grn.grn_no}"
432
  )
433
  else:
434
- logger.debug(f"PO not ready for auto-close after GRN {grn.grn_no}")
435
  else:
436
- logger.warning(f"Failed to update PO quantities for GRN {grn.grn_no}")
437
 
438
  except Exception as e:
439
- logger.error(f"Error in PO auto-close process for GRN {grn.grn_no}: {e}", exc_info=True)
440
  # Don't fail the GRN acceptance if PO update fails
441
 
442
- logger.info(f"GRN {grn.grn_no} accepted and stock movements processed successfully")
443
- return grn
444
 
445
  async def reject_grn(self, grn_id: str, status_change: GRNStatusChange) -> ScmGrn:
446
  """Reject GRN - goods are not accepted"""
 
7
  from datetime import datetime
8
  import logging
9
 
10
+ from app.core.utils import format_meta_field
11
  from app.purchases.receipts.models.model import ScmGrn, ScmGrnItem, ScmGrnIssue
12
  from app.purchases.orders.models.model import ScmPo, ScmPoItem
13
  from app.purchases.receipts.schemas.schema import GRNCreate, GRNStatusChange, GRNIssueRequest
 
35
  # TODO: wire taxonomy conversion once available
36
  return qty, uom
37
 
38
+ async def create_grn(self, grn_in: GRNCreate):
39
  """Create a new Goods Receipt Note"""
40
 
41
  po = None
 
114
  created_by=grn_in.created_by,
115
  shipment_id=grn_in.shipment_id,
116
  received_by=grn_in.received_by,
117
+ transporter=grn_in.transporter,
118
+ created_by_username=grn_in.created_by_username,
119
  )
120
  self.db.add(grn)
121
 
 
177
  logger.info(f"Created GRN {grn.grn_no} for PO {po.po_no} with {len(grn_in.items)} items")
178
  return grn_with_relations
179
 
180
+ async def get_grn(
181
+ self,
182
+ grn_identifier: str,
183
+ include_items: bool = True
184
+ ) -> Optional[Dict[str, Any]]:
185
+ """
186
+ Get GRN by ID (UUID) or GRN number with optional items and formatted meta fields
187
+
188
+ Args:
189
+ grn_identifier: Either a UUID string or GRN number (e.g., 'GRN-2026-000084')
190
+ include_items: Whether to include GRN items
191
+ """
192
 
193
+ # Determine if identifier is UUID or GRN number
194
+ try:
195
+ # Try to parse as UUID
196
+ from uuid import UUID
197
+ UUID(grn_identifier)
198
+ # If successful, query by grn_id
199
+ query = select(ScmGrn).where(ScmGrn.grn_id == grn_identifier)
200
+ except (ValueError, AttributeError):
201
+ # Not a valid UUID, treat as GRN number
202
+ query = select(ScmGrn).where(ScmGrn.grn_no == grn_identifier)
203
+
204
  if include_items:
205
  query = query.options(selectinload(ScmGrn.items))
206
+
207
  result = await self.db.execute(query)
208
+ grn_obj = result.scalar_one_or_none()
209
+
210
+ if not grn_obj:
211
+ return None
212
+
213
+ # Convert ORM to dict safely
214
+ grn_dict = grn_obj.__dict__.copy()
215
+
216
+ # Remove SQLAlchemy internal state
217
+ grn_dict.pop("_sa_instance_state", None)
218
+
219
+ # If items exist, convert them too
220
+ if include_items and grn_obj.items:
221
+ grn_dict["items"] = [
222
+ {
223
+ **item.__dict__,
224
+ "_sa_instance_state": None
225
+ }
226
+ for item in grn_obj.items
227
+ ]
228
+ for item in grn_dict["items"]:
229
+ item.pop("_sa_instance_state", None)
230
+
231
+ # Format meta field but keep created_by and created_at at root level
232
+ formatted_data = format_meta_field(grn_dict)
233
+
234
+ # Extract created_by and created_at from meta back to root level for schema compatibility
235
+ if "meta" in formatted_data:
236
+ if "created_by" in formatted_data["meta"]:
237
+ formatted_data["created_by"] = formatted_data["meta"]["created_by"]
238
+ if "created_at" in formatted_data["meta"]:
239
+ formatted_data["created_at"] = formatted_data["meta"]["created_at"]
240
+
241
+ return formatted_data
242
 
243
  async def list_grns(
244
  self,
 
268
  g.status,
269
  g.total_qty,
270
  g.remarks,
271
+ COALESCE(emp.full_name, g.created_by) AS received_by_name,
272
+ g.created_by,
273
+ g.created_by_username,
274
+ g.created_at,
275
+ g.updated_by,
276
+ g.updated_by_username,
277
+ g.updated_at
278
  FROM trans.scm_grn g
279
  LEFT JOIN trans.scm_po po ON g.po_id = po.po_id
280
  LEFT JOIN trans.merchants_ref sup ON g.supplier_id = sup.merchant_id
 
367
 
368
  # Full response
369
  full_list: List[Dict[str, Any]] = []
370
+
371
  for row in rows:
372
+ grn_dict = {
373
+ "grn_id": str(row.grn_id),
374
  "grn_no": row.grn_no,
375
+ "po_id": str(row.po_id) if row.po_id else None,
376
  "po_no": row.po_no,
377
+ "receiver_code": row.receiver_code,
378
  "supplier_code": row.supplier_code,
379
  "supplier_name": row.supplier_name,
380
  "warehouse_name": row.warehouse_name,
 
383
  "total_qty": _serialize(row.total_qty),
384
  "remarks": row.remarks,
385
  "received_by_name": row.received_by_name,
386
+ }
387
+
388
+ # 🔥 Apply meta formatting here
389
+ formatted = format_meta_field(grn_dict)
390
+
391
+ full_list.append(formatted)
392
+
393
  return full_list, total_count
394
 
395
  async def _change_status(
 
401
  ) -> ScmGrn:
402
  """Change GRN status with validation"""
403
 
404
+ # Get current GRN as ORM object for updating
405
+ # Determine if identifier is UUID or GRN number
406
+ try:
407
+ from uuid import UUID
408
+ UUID(grn_id)
409
+ # If successful, query by grn_id
410
+ query = select(ScmGrn).where(ScmGrn.grn_id == grn_id)
411
+ except (ValueError, AttributeError):
412
+ # Not a valid UUID, treat as GRN number
413
+ query = select(ScmGrn).where(ScmGrn.grn_no == grn_id)
414
+
415
+ result = await self.db.execute(query)
416
+ grn = result.scalar_one_or_none()
417
+
418
  if not grn:
419
  raise ValueError("GRN not found")
420
 
 
442
  async def accept_grn(self, grn_id: str, status_change: GRNStatusChange) -> ScmGrn:
443
  """Accept GRN - goods are accepted into inventory and update PO status"""
444
  # First, get the GRN with items to check PO relationship
445
+ grn_dict = await self.get_grn(grn_id, include_items=True)
446
+ if not grn_dict:
447
  raise ValueError("GRN not found")
448
 
449
+ # Extract the actual UUID for database operations
450
+ actual_grn_uuid = str(grn_dict["grn_id"])
451
+
452
+ # Change GRN status to accepted (returns ORM object)
453
+ grn_orm = await self._change_status(
454
  grn_id, "accepted", status_change.changed_by, status_change.remarks
455
  )
456
 
 
459
  from app.inventory.stock.services.service import StockService
460
  stock_service = StockService(self.db)
461
 
462
+ # Process GRN completion to update stock - use actual UUID
463
  stock_results = await stock_service.process_grn_completion(
464
+ grn_id=actual_grn_uuid,
465
  completed_by=status_change.changed_by
466
  )
467
 
468
+ logger.info(f"GRN {grn_dict['grn_no']} stock movements processed: {len(stock_results)} entries created")
469
 
470
  # Check if all stock transactions succeeded
471
  failed_transactions = [r for r in stock_results if not r[1]]
472
  if failed_transactions:
473
+ logger.error(f"Some stock transactions failed for GRN {grn_dict['grn_no']}: {failed_transactions}")
474
  # Continue with PO processing even if stock fails (can be retried)
475
 
476
  except Exception as e:
477
+ logger.error(f"Error processing stock movements for GRN {grn_dict['grn_no']}: {e}", exc_info=True)
478
  # Don't fail the GRN acceptance if stock processing fails (can be retried)
479
 
480
  # If GRN is linked to a PO, update PO received quantities and check for auto-close
481
+ if grn_dict.get("po_id"):
482
  try:
483
  # Import here to avoid circular imports
484
  from app.purchases.orders.services.service import OrdersService
 
486
 
487
  # Prepare GRN items data for PO update
488
  grn_items_data = []
489
+ for item in grn_dict.get("items", []):
490
+ if item.get("po_item_id"): # Only process items linked to PO
491
  grn_items_data.append({
492
+ 'po_item_id': item["po_item_id"],
493
+ 'acc_qty': item["acc_qty"],
494
+ 'acc_ord_uom_qty': item.get("ord_uom_qty") or item["acc_qty"],
495
+ 'acc_ord_uom': item.get("ord_uom") or item["uom"],
496
+ 'sku': item["sku"]
497
  })
498
 
499
  if grn_items_data:
 
501
  update_success = await po_service.update_po_received_quantities(grn_items_data)
502
 
503
  if update_success:
504
+ logger.info(f"Updated PO received quantities for GRN {grn_dict['grn_no']}")
505
 
506
  # Check if PO should be auto-closed
507
  closed_po = await po_service.check_and_auto_close_po(
508
+ str(grn_dict["po_id"]),
509
  status_change.changed_by
510
  )
511
 
512
  if closed_po:
513
  logger.info(
514
+ f"PO {closed_po.po_no} auto-closed after accepting GRN {grn_dict['grn_no']}"
515
  )
516
  else:
517
+ logger.debug(f"PO not ready for auto-close after GRN {grn_dict['grn_no']}")
518
  else:
519
+ logger.warning(f"Failed to update PO quantities for GRN {grn_dict['grn_no']}")
520
 
521
  except Exception as e:
522
+ logger.error(f"Error in PO auto-close process for GRN {grn_dict['grn_no']}: {e}", exc_info=True)
523
  # Don't fail the GRN acceptance if PO update fails
524
 
525
+ logger.info(f"GRN {grn_dict['grn_no']} accepted and stock movements processed successfully")
526
+ return grn_orm
527
 
528
  async def reject_grn(self, grn_id: str, status_change: GRNStatusChange) -> ScmGrn:
529
  """Reject GRN - goods are not accepted"""
app/system_users/schemas/schema.py CHANGED
@@ -1,6 +1,7 @@
1
  """System User schemas for request/response models."""
2
  from datetime import datetime
3
  from typing import Optional, List, Dict
 
4
  from pydantic import BaseModel, Field, EmailStr, validator
5
  from app.system_users.models.model import UserStatus
6
 
@@ -52,9 +53,9 @@ class CreateUserRequest(BaseModel):
52
 
53
  username: str = Field(..., description="Unique username", min_length=3, max_length=50)
54
  email: EmailStr = Field(..., description="Email address")
55
- merchant_id: str = Field(..., description="Merchant identifier")
56
  merchant_type: Optional[str] = Field(None, description="Merchant type (ncnf, cnf, distributor, retail, company)")
57
- password: str = Field(..., description="Password", min_length=8, max_length=100)
58
  full_name: str = Field(..., description="Full name", min_length=1, max_length=100)
59
  role_id: str = Field(..., description="Role identifier")
60
  status: UserStatus = Field(default=UserStatus.ACTIVE, description="Account status")
@@ -66,17 +67,17 @@ class CreateUserRequest(BaseModel):
66
  raise ValueError("Username can only contain alphanumeric characters, underscores, and periods")
67
  return v.lower()
68
 
69
- @validator("password")
70
- def validate_password(cls, v):
71
- if len(v) < 8:
72
- raise ValueError("Password must be at least 8 characters long")
73
- if not any(c.isupper() for c in v):
74
- raise ValueError("Password must contain at least one uppercase letter")
75
- if not any(c.islower() for c in v):
76
- raise ValueError("Password must contain at least one lowercase letter")
77
- if not any(c.isdigit() for c in v):
78
- raise ValueError("Password must contain at least one digit")
79
- return v
80
 
81
 
82
  class UpdateUserRequest(BaseModel):
 
1
  """System User schemas for request/response models."""
2
  from datetime import datetime
3
  from typing import Optional, List, Dict
4
+ from uuid import UUID
5
  from pydantic import BaseModel, Field, EmailStr, validator
6
  from app.system_users.models.model import UserStatus
7
 
 
53
 
54
  username: str = Field(..., description="Unique username", min_length=3, max_length=50)
55
  email: EmailStr = Field(..., description="Email address")
56
+ merchant_id: UUID = Field(..., description="Merchant identifier")
57
  merchant_type: Optional[str] = Field(None, description="Merchant type (ncnf, cnf, distributor, retail, company)")
58
+ password: str = Field(..., description="Password", min_length=6, max_length=100)
59
  full_name: str = Field(..., description="Full name", min_length=1, max_length=100)
60
  role_id: str = Field(..., description="Role identifier")
61
  status: UserStatus = Field(default=UserStatus.ACTIVE, description="Account status")
 
67
  raise ValueError("Username can only contain alphanumeric characters, underscores, and periods")
68
  return v.lower()
69
 
70
+ # @validator("password")
71
+ # def validate_password(cls, v):
72
+ # if len(v) < 6:
73
+ # raise ValueError("Password must be at least 6 characters long")
74
+ # if not any(c.isupper() for c in v):
75
+ # raise ValueError("Password must contain at least one uppercase letter")
76
+ # if not any(c.islower() for c in v):
77
+ # raise ValueError("Password must contain at least one lowercase letter")
78
+ # if not any(c.isdigit() for c in v):
79
+ # raise ValueError("Password must contain at least one digit")
80
+ # return v
81
 
82
 
83
  class UpdateUserRequest(BaseModel):
app/system_users/services/service.py CHANGED
@@ -144,13 +144,13 @@ class SystemUserService:
144
  password_hash = self.get_password_hash(user_data.password)
145
 
146
  user_model = SystemUserModel(
147
- user_id=user_id,
148
  username=user_data.username.lower(),
149
  email=user_data.email.lower(),
150
  password_hash=password_hash,
151
  full_name=user_data.full_name,
152
  role_id=user_data.role_id,
153
- merchant_id=user_data.merchant_id,
154
  merchant_type=user_data.merchant_type,
155
  status=UserStatus.ACTIVE, # Set as active by default
156
  last_login=None,
 
144
  password_hash = self.get_password_hash(user_data.password)
145
 
146
  user_model = SystemUserModel(
147
+ user_id=str(user_id),
148
  username=user_data.username.lower(),
149
  email=user_data.email.lower(),
150
  password_hash=password_hash,
151
  full_name=user_data.full_name,
152
  role_id=user_data.role_id,
153
+ merchant_id=str(user_data.merchant_id),
154
  merchant_type=user_data.merchant_type,
155
  status=UserStatus.ACTIVE, # Set as active by default
156
  last_login=None,
app/trade_invoices/controllers/router.py CHANGED
@@ -50,11 +50,13 @@ async def create_invoice(
50
  """
51
  try:
52
  created_by = current_user.user_id
 
53
 
54
  invoice, errors = await TradeInvoiceService.create_draft_invoice(
55
  db=db,
56
  invoice_data=invoice_data,
57
- created_by=created_by
 
58
  )
59
 
60
  if errors:
@@ -165,12 +167,14 @@ async def edit_invoice(
165
  """
166
  try:
167
  updated_by = current_user.user_id
 
168
 
169
  invoice, errors = await TradeInvoiceService.edit_draft_invoice(
170
  db=db,
171
  invoice_id=invoice_id,
172
  invoice_data=invoice_data,
173
- updated_by=updated_by
 
174
  )
175
 
176
  if errors:
@@ -226,12 +230,14 @@ async def update_invoice_status(
226
  """
227
  try:
228
  performed_by = current_user.user_id
 
229
 
230
  invoice, errors = await TradeInvoiceService.update_invoice_status(
231
  db=db,
232
  invoice_id=invoice_id,
233
  action_request=action_request,
234
- performed_by=performed_by
 
235
  )
236
 
237
  if errors:
 
50
  """
51
  try:
52
  created_by = current_user.user_id
53
+ created_by_username = current_user.username
54
 
55
  invoice, errors = await TradeInvoiceService.create_draft_invoice(
56
  db=db,
57
  invoice_data=invoice_data,
58
+ created_by=created_by,
59
+ created_by_username=created_by_username
60
  )
61
 
62
  if errors:
 
167
  """
168
  try:
169
  updated_by = current_user.user_id
170
+ updated_by_username = current_user.username
171
 
172
  invoice, errors = await TradeInvoiceService.edit_draft_invoice(
173
  db=db,
174
  invoice_id=invoice_id,
175
  invoice_data=invoice_data,
176
+ updated_by=updated_by,
177
+ updated_by_username=updated_by_username
178
  )
179
 
180
  if errors:
 
230
  """
231
  try:
232
  performed_by = current_user.user_id
233
+ performed_by_username = current_user.username
234
 
235
  invoice, errors = await TradeInvoiceService.update_invoice_status(
236
  db=db,
237
  invoice_id=invoice_id,
238
  action_request=action_request,
239
+ performed_by=performed_by,
240
+ performed_by_username=performed_by_username
241
  )
242
 
243
  if errors:
app/trade_invoices/models/model.py CHANGED
@@ -99,8 +99,11 @@ class ScmInvoice(Base):
99
 
100
  # Audit fields
101
  created_by = Column(String(64), nullable=True)
 
102
  created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
103
  updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
 
 
104
 
105
  # Relationships
106
  items = relationship("ScmInvoiceItem", back_populates="invoice", cascade="all, delete-orphan")
 
99
 
100
  # Audit fields
101
  created_by = Column(String(64), nullable=True)
102
+ created_by_username = Column(String(128), nullable=True)
103
  created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
104
  updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
105
+ updated_by = Column(String(64), nullable=True)
106
+ updated_by_username = Column(String(128), nullable=True)
107
 
108
  # Relationships
109
  items = relationship("ScmInvoiceItem", back_populates="invoice", cascade="all, delete-orphan")
app/trade_invoices/services/service.py CHANGED
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
13
  from sqlalchemy import select, and_, func, text
14
  from sqlalchemy.orm import selectinload
15
 
 
16
  from app.trade_invoices.models.model import ScmInvoice, ScmInvoiceItem, ScmInvoiceStatusLog
17
  from app.trade_invoices.schemas.schema import (
18
  GSTModel, InvoiceCreate, InvoiceActionRequest, InvoiceValidationError, POItemModel, POShipmentSummary, POSummaryModel, PricingModel, PurchaseOrderResponseModel, QuantityModel
@@ -39,7 +40,8 @@ class TradeInvoiceService:
39
  async def create_draft_invoice(
40
  db: AsyncSession,
41
  invoice_data: InvoiceCreate,
42
- created_by: str
 
43
  ) -> Tuple[ScmInvoice, List[InvoiceValidationError]]:
44
  """
45
  Create draft invoice (PO-driven, no GRN)
@@ -130,7 +132,9 @@ class TradeInvoiceService:
130
  round_off_amt=invoice_data.additional_charges.round_off or 0,
131
 
132
  remarks=invoice_data.remarks,
133
- created_by=created_by
 
 
134
  )
135
 
136
  db.add(invoice)
@@ -306,7 +310,8 @@ class TradeInvoiceService:
306
  db: AsyncSession,
307
  invoice_id: UUID,
308
  action_request: InvoiceActionRequest,
309
- performed_by: str
 
310
  ) -> Tuple[ScmInvoice, List[InvoiceValidationError]]:
311
  """
312
  Update invoice status following state machine rules
@@ -350,6 +355,8 @@ class TradeInvoiceService:
350
  # Update invoice
351
  invoice.status = new_status
352
  invoice.updated_at = dt.datetime.utcnow()
 
 
353
 
354
  # Create status log
355
  status_log = ScmInvoiceStatusLog(
@@ -460,7 +467,7 @@ class TradeInvoiceService:
460
  dict(row) for row in logs_result.mappings().all()
461
  ]
462
 
463
- return response
464
 
465
  @staticmethod
466
  async def list_invoices(
@@ -523,7 +530,10 @@ class TradeInvoiceService:
523
 
524
  query = text(query_str)
525
  result = await db.execute(query, params)
526
- return [dict(row._mapping) for row in result.fetchall()]
 
 
 
527
 
528
  else:
529
 
@@ -545,7 +555,12 @@ class TradeInvoiceService:
545
  i.total_tax_amt,
546
  i.grand_total_amt,
547
  i.status,
548
- i.created_at
 
 
 
 
 
549
  FROM trans.scm_invoice i
550
  JOIN trans.scm_po po
551
  ON po.po_id = i.po_id
@@ -585,7 +600,10 @@ class TradeInvoiceService:
585
  result = await db.execute(text(sql), params)
586
  rows = result.mappings().all()
587
 
588
- return [dict(row) for row in rows]
 
 
 
589
 
590
  @staticmethod
591
  async def get_invoice_summary(
@@ -685,7 +703,7 @@ class TradeInvoiceService:
685
  )
686
 
687
  @staticmethod
688
- async def edit_draft_invoice(db: AsyncSession, invoice_id: str, invoice_data: dict, updated_by: str):
689
  EDITABLE_FIELDS = {
690
  "po_id": lambda v: UUID(v),
691
  "grn_id": lambda v: UUID(v),
@@ -699,6 +717,8 @@ class TradeInvoiceService:
699
  "packing_amt": lambda v: v,
700
  "other_charges_amt": lambda v: v,
701
  "round_off_amt": lambda v: v,
 
 
702
  }
703
  result = await db.execute(
704
  select(ScmInvoice).where(ScmInvoice.invoice_id == invoice_id)
@@ -727,6 +747,8 @@ class TradeInvoiceService:
727
  setattr(invoice, field, transformer(invoice_data[field]))
728
 
729
  invoice.updated_at = dt.datetime.utcnow()
 
 
730
 
731
  await db.commit()
732
  await db.refresh(invoice)
 
13
  from sqlalchemy import select, and_, func, text
14
  from sqlalchemy.orm import selectinload
15
 
16
+ from app.core.utils import format_meta_field
17
  from app.trade_invoices.models.model import ScmInvoice, ScmInvoiceItem, ScmInvoiceStatusLog
18
  from app.trade_invoices.schemas.schema import (
19
  GSTModel, InvoiceCreate, InvoiceActionRequest, InvoiceValidationError, POItemModel, POShipmentSummary, POSummaryModel, PricingModel, PurchaseOrderResponseModel, QuantityModel
 
40
  async def create_draft_invoice(
41
  db: AsyncSession,
42
  invoice_data: InvoiceCreate,
43
+ created_by: str,
44
+ created_by_username:str
45
  ) -> Tuple[ScmInvoice, List[InvoiceValidationError]]:
46
  """
47
  Create draft invoice (PO-driven, no GRN)
 
132
  round_off_amt=invoice_data.additional_charges.round_off or 0,
133
 
134
  remarks=invoice_data.remarks,
135
+ created_by=created_by,
136
+ created_by_username=created_by_username,
137
+ created_at=dt.datetime.utcnow()
138
  )
139
 
140
  db.add(invoice)
 
310
  db: AsyncSession,
311
  invoice_id: UUID,
312
  action_request: InvoiceActionRequest,
313
+ performed_by: str,
314
+ performed_by_username:str
315
  ) -> Tuple[ScmInvoice, List[InvoiceValidationError]]:
316
  """
317
  Update invoice status following state machine rules
 
355
  # Update invoice
356
  invoice.status = new_status
357
  invoice.updated_at = dt.datetime.utcnow()
358
+ invoice.updated_by = performed_by
359
+ invoice.updated_by_username = performed_by_username
360
 
361
  # Create status log
362
  status_log = ScmInvoiceStatusLog(
 
467
  dict(row) for row in logs_result.mappings().all()
468
  ]
469
 
470
+ return format_meta_field(response)
471
 
472
  @staticmethod
473
  async def list_invoices(
 
530
 
531
  query = text(query_str)
532
  result = await db.execute(query, params)
533
+ return [
534
+ format_meta_field(dict(row._mapping))
535
+ for row in result.fetchall()
536
+ ]
537
 
538
  else:
539
 
 
555
  i.total_tax_amt,
556
  i.grand_total_amt,
557
  i.status,
558
+ i.created_at,
559
+ i.updated_at,
560
+ i.created_by,
561
+ i.created_by_username,
562
+ i.updated_by,
563
+ i.updated_by_username
564
  FROM trans.scm_invoice i
565
  JOIN trans.scm_po po
566
  ON po.po_id = i.po_id
 
600
  result = await db.execute(text(sql), params)
601
  rows = result.mappings().all()
602
 
603
+ return [
604
+ format_meta_field(dict(row))
605
+ for row in rows
606
+ ]
607
 
608
  @staticmethod
609
  async def get_invoice_summary(
 
703
  )
704
 
705
  @staticmethod
706
+ async def edit_draft_invoice(db: AsyncSession, invoice_id: str, invoice_data: dict, updated_by: str, updated_by_username:str):
707
  EDITABLE_FIELDS = {
708
  "po_id": lambda v: UUID(v),
709
  "grn_id": lambda v: UUID(v),
 
717
  "packing_amt": lambda v: v,
718
  "other_charges_amt": lambda v: v,
719
  "round_off_amt": lambda v: v,
720
+ "updated_by": lambda v: v,
721
+ "updated_by_username": lambda v: v
722
  }
723
  result = await db.execute(
724
  select(ScmInvoice).where(ScmInvoice.invoice_id == invoice_id)
 
747
  setattr(invoice, field, transformer(invoice_data[field]))
748
 
749
  invoice.updated_at = dt.datetime.utcnow()
750
+ invoice.updated_by = updated_by
751
+ invoice.updated_by_username = updated_by_username
752
 
753
  await db.commit()
754
  await db.refresh(invoice)
app/trade_invoices/utils.py CHANGED
@@ -164,7 +164,7 @@ ALLOWED_INVOICE_PROJECTION_FIELDS = [
164
  "currency", "invoice_date", "payment_terms", "due_date",
165
  "subtotal_amt", "discount_amt", "taxable_amt", "cgst_amt", "sgst_amt", "igst_amt",
166
  "total_tax_amt", "grand_total_amt", "status", "reverse_charge", "remarks",
167
- "created_by", "created_at", "updated_at"
168
  ]
169
 
170
 
 
164
  "currency", "invoice_date", "payment_terms", "due_date",
165
  "subtotal_amt", "discount_amt", "taxable_amt", "cgst_amt", "sgst_amt", "igst_amt",
166
  "total_tax_amt", "grand_total_amt", "status", "reverse_charge", "remarks",
167
+ "created_by", "created_at", "updated_at", "created_by_username", "updated_by_username"
168
  ]
169
 
170
 
app/trade_relationships/controllers/router.py CHANGED
@@ -85,10 +85,11 @@ async def create_trade_relationship(
85
  """
86
  try:
87
  user_id = getattr(current_user, 'user_id', str(current_user.id) if hasattr(current_user, 'id') else 'unknown')
88
-
89
  relationship = await TradeRelationshipService.create_relationship(
90
  data=payload,
91
- created_by=user_id
 
92
  )
93
 
94
  logger.info(
 
85
  """
86
  try:
87
  user_id = getattr(current_user, 'user_id', str(current_user.id) if hasattr(current_user, 'id') else 'unknown')
88
+ user_name = getattr(current_user, 'username', 'unknown')
89
  relationship = await TradeRelationshipService.create_relationship(
90
  data=payload,
91
+ created_by=user_id,
92
+ created_by_username=user_name
93
  )
94
 
95
  logger.info(
app/trade_relationships/models/model.py CHANGED
@@ -2,7 +2,7 @@
2
  PostgreSQL models for SCM Trade Relationships.
3
  Defines the authoritative trade relationship between merchants in the supply chain.
4
  """
5
- from sqlalchemy import Column, String, Numeric, Text, TIMESTAMP, Date, Boolean, CheckConstraint, Index
6
  from sqlalchemy.dialects.postgresql import UUID, ARRAY
7
  from datetime import datetime, date
8
  import uuid
@@ -174,6 +174,9 @@ class ScmTradeRelationship(Base):
174
  onupdate=datetime.utcnow,
175
  comment="Timestamp when relationship was last updated"
176
  )
 
 
 
177
 
178
  def __repr__(self):
179
  return f"<ScmTradeRelationship(id={self.relationship_id}, from={self.from_merchant_id}, to={self.to_merchant_id}, status={self.status})>"
 
2
  PostgreSQL models for SCM Trade Relationships.
3
  Defines the authoritative trade relationship between merchants in the supply chain.
4
  """
5
+ from sqlalchemy import Column, String, Numeric, Text, TIMESTAMP, Date, Boolean, CheckConstraint, Index, func
6
  from sqlalchemy.dialects.postgresql import UUID, ARRAY
7
  from datetime import datetime, date
8
  import uuid
 
174
  onupdate=datetime.utcnow,
175
  comment="Timestamp when relationship was last updated"
176
  )
177
+ created_by_username = Column(String(128), nullable=True)
178
+ updated_by = Column(String(64), nullable=True)
179
+ updated_by_username = Column(String(128), nullable=True)
180
 
181
  def __repr__(self):
182
  return f"<ScmTradeRelationship(id={self.relationship_id}, from={self.from_merchant_id}, to={self.to_merchant_id}, status={self.status})>"
app/trade_relationships/schemas/schema.py CHANGED
@@ -208,10 +208,7 @@ class TradeRelationshipResponse(BaseModel):
208
  allowed_regions: Optional[List[str]] = Field(None, description="Allowed regions")
209
  allowed_categories: Optional[List[str]] = Field(None, description="Allowed categories")
210
  remarks: Optional[str] = Field(None, description="Additional notes")
211
- created_by: str = Field(..., description="Created by user")
212
- created_at: datetime = Field(..., description="Creation timestamp")
213
- updated_at: datetime = Field(..., description="Last update timestamp")
214
-
215
  # Computed fields
216
  is_valid: bool = Field(..., description="Whether relationship is currently valid")
217
 
 
208
  allowed_regions: Optional[List[str]] = Field(None, description="Allowed regions")
209
  allowed_categories: Optional[List[str]] = Field(None, description="Allowed categories")
210
  remarks: Optional[str] = Field(None, description="Additional notes")
211
+ meta: Optional[Dict[str, Any]] = Field(None, description="Additional metadata")
 
 
 
212
  # Computed fields
213
  is_valid: bool = Field(..., description="Whether relationship is currently valid")
214
 
app/trade_relationships/services/service.py CHANGED
@@ -12,6 +12,7 @@ from sqlalchemy import select, and_, or_, text, func, delete
12
  from sqlalchemy.orm import selectinload, aliased
13
  from fastapi import HTTPException, status
14
 
 
15
  from app.sql import async_session
16
  from app.core.logging import get_logger
17
  from app.trade_relationships.models.model import ScmTradeRelationship
@@ -52,7 +53,8 @@ class TradeRelationshipService:
52
  @staticmethod
53
  async def create_relationship(
54
  data: TradeRelationshipCreate,
55
- created_by: str
 
56
  ) -> TradeRelationshipResponse:
57
  """
58
  Create a new trade relationship.
@@ -108,6 +110,8 @@ class TradeRelationshipService:
108
  valid_to=data.valid_to,
109
  remarks=data.remarks,
110
  created_by=created_by,
 
 
111
  status=RelationshipStatus.DRAFT.value
112
  )
113
 
@@ -772,28 +776,35 @@ class TradeRelationshipService:
772
  to_merchant_code = to_details.get("code")
773
  to_merchant_name = to_details.get("name")
774
 
775
- return TradeRelationshipResponse(
776
- relationship_id=relationship.relationship_id,
777
- from_merchant_id=relationship.from_merchant_id,
778
- from_merchant_code=from_merchant_code,
779
- from_merchant_name=from_merchant_name,
780
- to_merchant_id=relationship.to_merchant_id,
781
- to_merchant_code=to_merchant_code,
782
- to_merchant_name=to_merchant_name,
783
- relationship_type=RelationshipType(relationship.relationship_type),
784
- status=RelationshipStatus(relationship.status),
785
- valid_from=relationship.valid_from,
786
- valid_to=relationship.valid_to,
787
- pricing_level=PricingLevel(relationship.pricing_level),
788
- payment_terms=PaymentTerms(relationship.payment_terms),
789
- credit_allowed=relationship.credit_allowed,
790
- credit_limit=relationship.credit_limit,
791
- currency=relationship.currency,
792
- allowed_regions=relationship.allowed_regions,
793
- allowed_categories=relationship.allowed_categories,
794
- remarks=relationship.remarks,
795
- created_by=relationship.created_by,
796
- created_at=relationship.created_at,
797
- updated_at=relationship.updated_at,
798
- is_valid=relationship.is_valid()
799
- )
 
 
 
 
 
 
 
 
12
  from sqlalchemy.orm import selectinload, aliased
13
  from fastapi import HTTPException, status
14
 
15
+ from app.core.utils import format_meta_field
16
  from app.sql import async_session
17
  from app.core.logging import get_logger
18
  from app.trade_relationships.models.model import ScmTradeRelationship
 
53
  @staticmethod
54
  async def create_relationship(
55
  data: TradeRelationshipCreate,
56
+ created_by: str,
57
+ created_by_username: str
58
  ) -> TradeRelationshipResponse:
59
  """
60
  Create a new trade relationship.
 
110
  valid_to=data.valid_to,
111
  remarks=data.remarks,
112
  created_by=created_by,
113
+ created_by_username=created_by_username,
114
+ created_at=datetime.utcnow(),
115
  status=RelationshipStatus.DRAFT.value
116
  )
117
 
 
776
  to_merchant_code = to_details.get("code")
777
  to_merchant_name = to_details.get("name")
778
 
779
+ data = {
780
+ "relationship_id": relationship.relationship_id,
781
+ "from_merchant_id": relationship.from_merchant_id,
782
+ "from_merchant_code": from_merchant_code,
783
+ "from_merchant_name": from_merchant_name,
784
+ "to_merchant_id": relationship.to_merchant_id,
785
+ "to_merchant_code": to_merchant_code,
786
+ "to_merchant_name": to_merchant_name,
787
+ "relationship_type": RelationshipType(relationship.relationship_type),
788
+ "status": RelationshipStatus(relationship.status),
789
+ "valid_from": relationship.valid_from,
790
+ "valid_to": relationship.valid_to,
791
+ "pricing_level": PricingLevel(relationship.pricing_level),
792
+ "payment_terms": PaymentTerms(relationship.payment_terms),
793
+ "credit_allowed": relationship.credit_allowed,
794
+ "credit_limit": relationship.credit_limit,
795
+ "currency": relationship.currency,
796
+ "allowed_regions": relationship.allowed_regions,
797
+ "allowed_categories": relationship.allowed_categories,
798
+ "remarks": relationship.remarks,
799
+ "created_by": relationship.created_by,
800
+ "created_by_username": relationship.created_by_username,
801
+ "updated_by": relationship.updated_by,
802
+ "updated_by_username": relationship.updated_by_username,
803
+ "created_at": relationship.created_at,
804
+ "updated_at": relationship.updated_at,
805
+ "is_valid": relationship.is_valid()
806
+ }
807
+
808
+ formatted = format_meta_field(data)
809
+
810
+ return TradeRelationshipResponse(**formatted)
app/trade_returns/services/service.py CHANGED
@@ -243,7 +243,8 @@ class TradeReturnService:
243
  async def create_draft_return(
244
  db: AsyncSession,
245
  return_data: ReturnCreate,
246
- created_by: str
 
247
  ) -> Tuple[Optional[TradeReturn], List[ValidationError]]:
248
  """Create a draft Trade Return"""
249
  errors = []
@@ -285,7 +286,9 @@ class TradeReturnService:
285
  supplier_name=invoice_info.supplier_name,
286
  reason_code=ReturnReasonCode(return_data.reason_code.value),
287
  remarks=return_data.remarks,
288
- created_by=created_by
 
 
289
  )
290
 
291
  db.add(trade_return)
@@ -379,7 +382,8 @@ class TradeReturnService:
379
  db: AsyncSession,
380
  return_id: UUID,
381
  action_request: ReturnActionRequest,
382
- performed_by: str
 
383
  ) -> Tuple[Optional[TradeReturn], List[ValidationError]]:
384
  """Update return status following approval lifecycle (submit/approve/reject/complete)"""
385
  errors = []
@@ -443,6 +447,7 @@ class TradeReturnService:
443
  # Execute inventory impact only on completion
444
  trade_return.updated_by = performed_by
445
  trade_return.updated_at = datetime.utcnow()
 
446
  # Mark completion timestamp
447
  try:
448
  # Add completed_at if present on model
 
243
  async def create_draft_return(
244
  db: AsyncSession,
245
  return_data: ReturnCreate,
246
+ created_by: str,
247
+ created_by_name: Optional[str] = None
248
  ) -> Tuple[Optional[TradeReturn], List[ValidationError]]:
249
  """Create a draft Trade Return"""
250
  errors = []
 
286
  supplier_name=invoice_info.supplier_name,
287
  reason_code=ReturnReasonCode(return_data.reason_code.value),
288
  remarks=return_data.remarks,
289
+ created_by=created_by,
290
+ created_by_name=created_by_name,
291
+ created_at=datetime.utcnow()
292
  )
293
 
294
  db.add(trade_return)
 
382
  db: AsyncSession,
383
  return_id: UUID,
384
  action_request: ReturnActionRequest,
385
+ performed_by: str,
386
+ performed_by_name: Optional[str] = None
387
  ) -> Tuple[Optional[TradeReturn], List[ValidationError]]:
388
  """Update return status following approval lifecycle (submit/approve/reject/complete)"""
389
  errors = []
 
447
  # Execute inventory impact only on completion
448
  trade_return.updated_by = performed_by
449
  trade_return.updated_at = datetime.utcnow()
450
+ trade_return.updated_by_username = performed_by_name
451
  # Mark completion timestamp
452
  try:
453
  # Add completed_at if present on model
app/trade_sales/schemas/schema.py CHANGED
@@ -59,7 +59,7 @@ class ClientOrderSummary(BaseModel):
59
  shipped_qty: Decimal
60
  pending_qty: Decimal
61
  status: str # derived: pending | partial | completed | closed
62
-
63
  class Config:
64
  from_attributes = True
65
 
@@ -92,6 +92,7 @@ class ClientOrderDetail(BaseModel):
92
  total_amt: Decimal
93
  remarks: Optional[str] = None
94
  items: List[ClientOrderItem]
 
95
 
96
  class Config:
97
  from_attributes = True
 
59
  shipped_qty: Decimal
60
  pending_qty: Decimal
61
  status: str # derived: pending | partial | completed | closed
62
+ meta: Optional[Dict[str, Any]] = None # For any additional info like priority, tags, etc.
63
  class Config:
64
  from_attributes = True
65
 
 
92
  total_amt: Decimal
93
  remarks: Optional[str] = None
94
  items: List[ClientOrderItem]
95
+ meta: Optional[Dict[str, Any]] = None # For any additional info like priority, tags, etc.
96
 
97
  class Config:
98
  from_attributes = True
app/trade_sales/services/service.py CHANGED
@@ -11,6 +11,7 @@ from decimal import Decimal
11
  from datetime import datetime, date
12
  import logging
13
 
 
14
  from app.purchases.orders.models.model import ScmPo, ScmPoItem
15
  from app.trade_sales.models.model import ScmTradeShipment, ScmTradeShipmentItem
16
  from app.inventory.stock.services.service import StockService, StockTransaction, TransactionType, ReferenceType
@@ -91,7 +92,13 @@ class TradeSalesService:
91
  WHEN COALESCE(SUM(tsi.shipped_qty), 0) = 0 THEN 'pending'
92
  WHEN COALESCE(SUM(tsi.shipped_qty), 0) < SUM(poi.ord_qty) THEN 'partial'
93
  ELSE 'completed'
94
- END AS fulfillment_status
 
 
 
 
 
 
95
  FROM trans.scm_po po
96
  LEFT JOIN trans.scm_po_item poi ON po.po_id = poi.po_id
97
  LEFT JOIN trans.scm_trade_shipment_item tsi ON poi.po_item_id = tsi.po_item_id
@@ -180,7 +187,8 @@ class TradeSalesService:
180
  order_dict = dict(order._mapping)
181
  # Map fulfillment_status to status for Pydantic model
182
  order_dict["status"] = order_dict.get("fulfillment_status")
183
- orders_list.append(ClientOrderSummary(**order_dict))
 
184
  return orders_list, total_count
185
 
186
  except Exception as e:
@@ -262,6 +270,17 @@ class TradeSalesService:
262
  })
263
 
264
  order_dict["items"] = items
 
 
 
 
 
 
 
 
 
 
 
265
  return ClientOrderDetail(**order_dict)
266
 
267
  except Exception as e:
 
11
  from datetime import datetime, date
12
  import logging
13
 
14
+ from app.core.utils import format_meta_field
15
  from app.purchases.orders.models.model import ScmPo, ScmPoItem
16
  from app.trade_sales.models.model import ScmTradeShipment, ScmTradeShipmentItem
17
  from app.inventory.stock.services.service import StockService, StockTransaction, TransactionType, ReferenceType
 
92
  WHEN COALESCE(SUM(tsi.shipped_qty), 0) = 0 THEN 'pending'
93
  WHEN COALESCE(SUM(tsi.shipped_qty), 0) < SUM(poi.ord_qty) THEN 'partial'
94
  ELSE 'completed'
95
+ END AS fulfillment_status,
96
+ po.created_by,
97
+ po.created_at,
98
+ po.updated_by,
99
+ po.updated_at,
100
+ po.created_by_username,
101
+ po.updated_by_username
102
  FROM trans.scm_po po
103
  LEFT JOIN trans.scm_po_item poi ON po.po_id = poi.po_id
104
  LEFT JOIN trans.scm_trade_shipment_item tsi ON poi.po_item_id = tsi.po_item_id
 
187
  order_dict = dict(order._mapping)
188
  # Map fulfillment_status to status for Pydantic model
189
  order_dict["status"] = order_dict.get("fulfillment_status")
190
+ formatted_data = format_meta_field(order_dict)
191
+ orders_list.append(ClientOrderSummary(**formatted_data))
192
  return orders_list, total_count
193
 
194
  except Exception as e:
 
270
  })
271
 
272
  order_dict["items"] = items
273
+ meta_fields = {
274
+ "created_by": order_dict.pop("created_by", None),
275
+ "created_at": order_dict.pop("created_at", None),
276
+ "updated_by": order_dict.pop("updated_by", None),
277
+ "updated_at": order_dict.pop("updated_at", None),
278
+ "created_by_username": order_dict.pop("created_by_username", None),
279
+ "updated_by_username": order_dict.pop("updated_by_username", None),
280
+ }
281
+
282
+ order_dict.update(format_meta_field(meta_fields))
283
+
284
  return ClientOrderDetail(**order_dict)
285
 
286
  except Exception as e: