Spaces:
Running
Running
Merge https://huggingface.co/spaces/cuatrolabs/cuatrolabs-scm-ms
Browse files- app/catalogues/controllers/router.py +1 -0
- app/catalogues/schemas/schema.py +10 -1
- app/catalogues/services/service.py +37 -16
- app/employees/services/service.py +47 -7
- app/inventory/adjustments/controllers/router.py +4 -3
- app/inventory/adjustments/schemas/schema.py +1 -3
- app/inventory/adjustments/services/service.py +127 -76
- app/inventory/stock/models/model.py +5 -0
- app/inventory/stock/services/service.py +35 -25
- app/po_returns/controllers/router.py +4 -2
- app/po_returns/models/model.py +18 -3
- app/po_returns/schemas/schema.py +2 -6
- app/po_returns/services/service.py +111 -7
- app/purchases/orders/controllers/router.py +5 -0
- app/purchases/orders/models/model.py +7 -1
- app/purchases/orders/schemas/schema.py +5 -3
- app/purchases/orders/services/service.py +21 -8
- app/purchases/receipts/controllers/router.py +5 -5
- app/purchases/receipts/models/model.py +5 -2
- app/purchases/receipts/schemas/schema.py +2 -1
- app/purchases/receipts/services/service.py +123 -40
- app/system_users/schemas/schema.py +14 -13
- app/system_users/services/service.py +2 -2
- app/trade_invoices/controllers/router.py +9 -3
- app/trade_invoices/models/model.py +3 -0
- app/trade_invoices/services/service.py +30 -8
- app/trade_invoices/utils.py +1 -1
- app/trade_relationships/controllers/router.py +3 -2
- app/trade_relationships/models/model.py +4 -1
- app/trade_relationships/schemas/schema.py +1 -4
- app/trade_relationships/services/service.py +37 -26
- app/trade_returns/services/service.py +8 -3
- app/trade_sales/schemas/schema.py +2 -1
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 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["
|
| 443 |
-
update_data_flat["
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 1010 |
-
projected_item[field] =
|
| 1011 |
items.append(projected_item)
|
| 1012 |
else:
|
| 1013 |
-
items.append(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
except Exception as e:
|
| 505 |
-
logger.
|
| 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:
|
| 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("/",
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 388 |
-
adjustment_id
|
| 389 |
-
merchant_id
|
| 390 |
-
warehouse_id
|
| 391 |
-
sku
|
| 392 |
-
batch_no
|
| 393 |
-
adj_type
|
| 394 |
-
qty
|
| 395 |
-
reason
|
| 396 |
-
status
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 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 |
-
|
| 706 |
-
adjustment_id
|
| 707 |
-
merchant_id
|
| 708 |
-
warehouse_id
|
| 709 |
-
sku
|
| 710 |
-
batch_no
|
| 711 |
-
adj_type
|
| 712 |
-
qty
|
| 713 |
-
reason
|
| 714 |
-
status
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
approved_at
|
| 719 |
-
applied_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 |
-
|
| 819 |
-
adjustment_id
|
| 820 |
-
merchant_id
|
| 821 |
-
warehouse_id
|
| 822 |
-
sku
|
| 823 |
-
batch_no
|
| 824 |
-
adj_type
|
| 825 |
-
qty
|
| 826 |
-
reason
|
| 827 |
-
status
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
approved_at
|
| 832 |
-
applied_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 |
-
|
| 1077 |
-
|
| 1078 |
-
adjustment_master_id
|
| 1079 |
-
adjustment_number
|
| 1080 |
-
merchant_id
|
| 1081 |
-
warehouse_id
|
| 1082 |
-
warehouse_code
|
| 1083 |
-
warehouse_name
|
| 1084 |
-
adjustment_date
|
| 1085 |
-
description
|
| 1086 |
-
additional_notes
|
| 1087 |
-
status
|
| 1088 |
-
total_items
|
| 1089 |
-
total_value
|
| 1090 |
-
created_by
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
approved_at
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
| 510 |
}
|
| 511 |
-
|
| 512 |
# Apply projection if specified
|
| 513 |
if projection_list:
|
| 514 |
projected_item = {}
|
| 515 |
for field in projection_list:
|
| 516 |
-
if field in
|
| 517 |
-
projected_item[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
|
| 522 |
-
projected_item[field] =
|
| 523 |
items.append(projected_item)
|
| 524 |
else:
|
| 525 |
-
items.append(
|
| 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,
|
| 546 |
-
|
| 547 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 609 |
-
|
| 610 |
"created_by": row.created_by,
|
| 611 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
}
|
| 613 |
-
|
| 614 |
# Apply projection if specified
|
| 615 |
if projection_list:
|
| 616 |
projected_item = {}
|
| 617 |
for field in projection_list:
|
| 618 |
-
if field in
|
| 619 |
-
projected_item[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
|
| 624 |
-
projected_item[field] =
|
| 625 |
items.append(projected_item)
|
| 626 |
else:
|
| 627 |
-
items.append(
|
| 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 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 299 |
status="DRAFT",
|
| 300 |
reason_code=return_data.reason_code.value,
|
| 301 |
remarks=return_data.remarks,
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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
|
| 429 |
-
projected_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(
|
| 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
|
| 54 |
-
"grn_no": grn
|
| 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}"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 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(
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
if include_items:
|
| 183 |
query = query.options(selectinload(ScmGrn.items))
|
| 184 |
-
|
| 185 |
result = await self.db.execute(query)
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 366 |
-
if not
|
| 367 |
raise ValueError("GRN not found")
|
| 368 |
|
| 369 |
-
#
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 382 |
completed_by=status_change.changed_by
|
| 383 |
)
|
| 384 |
|
| 385 |
-
logger.info(f"GRN {
|
| 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 {
|
| 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 {
|
| 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
|
| 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
|
| 407 |
-
if item.po_item_id: # Only process items linked to PO
|
| 408 |
grn_items_data.append({
|
| 409 |
-
'po_item_id': item
|
| 410 |
-
'acc_qty': item
|
| 411 |
-
'acc_ord_uom_qty': item.ord_uom_qty or item
|
| 412 |
-
'acc_ord_uom': item.ord_uom or item
|
| 413 |
-
'sku': item
|
| 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 {
|
| 422 |
|
| 423 |
# Check if PO should be auto-closed
|
| 424 |
closed_po = await po_service.check_and_auto_close_po(
|
| 425 |
-
str(
|
| 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 {
|
| 432 |
)
|
| 433 |
else:
|
| 434 |
-
logger.debug(f"PO not ready for auto-close after GRN {
|
| 435 |
else:
|
| 436 |
-
logger.warning(f"Failed to update PO quantities for GRN {
|
| 437 |
|
| 438 |
except Exception as e:
|
| 439 |
-
logger.error(f"Error in PO auto-close process for GRN {
|
| 440 |
# Don't fail the GRN acceptance if PO update fails
|
| 441 |
|
| 442 |
-
logger.info(f"GRN {
|
| 443 |
-
return
|
| 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:
|
| 56 |
merchant_type: Optional[str] = Field(None, description="Merchant type (ncnf, cnf, distributor, retail, company)")
|
| 57 |
-
password: str = Field(..., description="Password", min_length=
|
| 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 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 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 [
|
|
|
|
|
|
|
|
|
|
| 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 [
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 776 |
-
relationship_id
|
| 777 |
-
from_merchant_id
|
| 778 |
-
from_merchant_code
|
| 779 |
-
from_merchant_name
|
| 780 |
-
to_merchant_id
|
| 781 |
-
to_merchant_code
|
| 782 |
-
to_merchant_name
|
| 783 |
-
relationship_type
|
| 784 |
-
status
|
| 785 |
-
valid_from
|
| 786 |
-
valid_to
|
| 787 |
-
pricing_level
|
| 788 |
-
payment_terms
|
| 789 |
-
credit_allowed
|
| 790 |
-
credit_limit
|
| 791 |
-
currency
|
| 792 |
-
allowed_regions
|
| 793 |
-
allowed_categories
|
| 794 |
-
remarks
|
| 795 |
-
created_by
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 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 |
-
|
|
|
|
| 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:
|