MukeshKapoor25 commited on
Commit
f3c922d
·
1 Parent(s): 86f4f77

feat: Implement distributor management functionality with CRUD operations and pagination

Browse files
app/app.py CHANGED
@@ -11,6 +11,7 @@ from app.controllers.reports import router as reports_router
11
  from app.controllers.employees import router as employees_router
12
  from app.controllers.reference import router as reference_router
13
  from app.controllers.bidders import router as bidders_router
 
14
 
15
  setup_logging(settings.LOG_LEVEL)
16
 
 
11
  from app.controllers.employees import router as employees_router
12
  from app.controllers.reference import router as reference_router
13
  from app.controllers.bidders import router as bidders_router
14
+ from app.controllers.distributors import router as distributors_router
15
 
16
  setup_logging(settings.LOG_LEVEL)
17
 
app/controllers/distributors.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, status, Query, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from app.db.session import get_db
4
+ from app.services.distributor_service import DistributorService
5
+ from app.schemas.distributor import DistributorCreate, DistributorOut
6
+ from app.schemas.paginated_response import PaginatedResponse
7
+ from typing import Optional, List
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Common query parameter descriptions
13
+ PAGE_DESC = "Page number (1-indexed)"
14
+ PAGE_SIZE_DESC = "Number of items per page"
15
+ ORDER_BY_DESC = "Field to order by"
16
+ ORDER_DIR_DESC = "Order direction (asc|desc)"
17
+ TERRITORY_DESC = "Territory/Region name"
18
+ STATUS_DESC = "Distributor status (active|inactive|pending)"
19
+
20
+ router = APIRouter(prefix="/api/v1/distributors", tags=["distributors"])
21
+
22
+ @router.get(
23
+ "/",
24
+ response_model=PaginatedResponse[DistributorOut],
25
+ summary="List all distributors",
26
+ response_description="Paginated list of distributors"
27
+ )
28
+ def list_distributors(
29
+ page: int = Query(1, ge=1, description=PAGE_DESC),
30
+ page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
31
+ order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
32
+ order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
33
+ db: Session = Depends(get_db)
34
+ ):
35
+ """Get all distributors with pagination and ordering"""
36
+ try:
37
+ logger.info(f"Listing distributors: page={page}, page_size={page_size}")
38
+ distributor_service = DistributorService(db)
39
+ result = distributor_service.list(
40
+ page=page,
41
+ page_size=page_size,
42
+ order_by=order_by,
43
+ order_direction=order_dir.upper()
44
+ )
45
+ logger.info(f"Successfully retrieved {len(result.items)} distributors")
46
+ return result
47
+ except Exception as e:
48
+ logger.error(f"Error listing distributors: {e}")
49
+ return PaginatedResponse[DistributorOut](
50
+ items=[],
51
+ page=page,
52
+ page_size=page_size,
53
+ total=0
54
+ )
55
+
56
+ @router.get(
57
+ "/territory/{territory}",
58
+ response_model=PaginatedResponse[DistributorOut],
59
+ summary="List distributors by territory",
60
+ response_description="Paginated list of distributors for a specific territory"
61
+ )
62
+ def list_distributors_by_territory(
63
+ territory: str,
64
+ page: int = Query(1, ge=1, description=PAGE_DESC),
65
+ page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
66
+ order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
67
+ order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
68
+ db: Session = Depends(get_db)
69
+ ):
70
+ """Get all distributors for a specific territory with pagination and ordering"""
71
+ try:
72
+ logger.info(f"Listing distributors for territory {territory}: page={page}, page_size={page_size}")
73
+ distributor_service = DistributorService(db)
74
+ result = distributor_service.get_by_territory(
75
+ territory=territory,
76
+ page=page,
77
+ page_size=page_size,
78
+ order_by=order_by,
79
+ order_dir=order_dir.upper()
80
+ )
81
+ logger.info(f"Successfully retrieved {len(result.items)} distributors for territory {territory}")
82
+ return result
83
+ except Exception as e:
84
+ logger.error(f"Error listing distributors for territory {territory}: {e}")
85
+ return PaginatedResponse[DistributorOut](
86
+ items=[],
87
+ page=page,
88
+ page_size=page_size,
89
+ total=0
90
+ )
91
+
92
+ @router.get(
93
+ "/status/{status}",
94
+ response_model=PaginatedResponse[DistributorOut],
95
+ summary="List distributors by status",
96
+ response_description="Paginated list of distributors with a specific status"
97
+ )
98
+ def list_distributors_by_status(
99
+ status: str,
100
+ page: int = Query(1, ge=1, description=PAGE_DESC),
101
+ page_size: int = Query(10, ge=1, le=100, description=PAGE_SIZE_DESC),
102
+ order_by: Optional[str] = Query("Id", description=ORDER_BY_DESC),
103
+ order_dir: Optional[str] = Query("asc", description=ORDER_DIR_DESC),
104
+ db: Session = Depends(get_db)
105
+ ):
106
+ """Get all distributors with a specific status with pagination and ordering"""
107
+ try:
108
+ logger.info(f"Listing distributors with status {status}: page={page}, page_size={page_size}")
109
+ distributor_service = DistributorService(db)
110
+ result = distributor_service.get_by_status(
111
+ status=status,
112
+ page=page,
113
+ page_size=page_size,
114
+ order_by=order_by,
115
+ order_dir=order_dir.upper()
116
+ )
117
+ logger.info(f"Successfully retrieved {len(result.items)} distributors with status {status}")
118
+ return result
119
+ except Exception as e:
120
+ logger.error(f"Error listing distributors with status {status}: {e}")
121
+ return PaginatedResponse[DistributorOut](
122
+ items=[],
123
+ page=page,
124
+ page_size=page_size,
125
+ total=0
126
+ )
127
+
128
+ @router.get(
129
+ "/territories",
130
+ response_model=List[str],
131
+ summary="Get all territories",
132
+ response_description="List of unique territories"
133
+ )
134
+ def get_territories(db: Session = Depends(get_db)):
135
+ """Get all unique territories"""
136
+ try:
137
+ distributor_service = DistributorService(db)
138
+ territories = distributor_service.get_territories()
139
+ logger.info(f"Successfully retrieved {len(territories)} territories")
140
+ return territories
141
+ except Exception as e:
142
+ logger.error(f"Error getting territories: {e}")
143
+ return []
144
+
145
+ @router.get(
146
+ "/{distributor_id}",
147
+ response_model=DistributorOut,
148
+ summary="Get a distributor by ID",
149
+ response_description="Distributor details"
150
+ )
151
+ def get_distributor(distributor_id: int, db: Session = Depends(get_db)):
152
+ """Get a specific distributor by ID"""
153
+ try:
154
+ distributor_service = DistributorService(db)
155
+ return distributor_service.get(distributor_id)
156
+ except Exception as e:
157
+ logger.error(f"Error getting distributor {distributor_id}: {e}")
158
+ raise HTTPException(
159
+ status_code=status.HTTP_404_NOT_FOUND,
160
+ detail=f"Distributor {distributor_id} not found"
161
+ )
162
+
163
+ @router.post(
164
+ "/",
165
+ response_model=DistributorOut,
166
+ status_code=status.HTTP_201_CREATED,
167
+ summary="Create a new distributor",
168
+ response_description="Created distributor details"
169
+ )
170
+ def create_distributor(
171
+ distributor_in: DistributorCreate,
172
+ db: Session = Depends(get_db)
173
+ ):
174
+ """Create a new distributor"""
175
+ try:
176
+ distributor_service = DistributorService(db)
177
+ logger.info(f"Creating new distributor: {distributor_in.company_name}")
178
+
179
+ result = distributor_service.create(distributor_in.dict())
180
+ logger.info(f"Successfully created distributor {result.id}: {result.company_name}")
181
+ return result
182
+ except ValueError as e:
183
+ logger.error(f"Validation error creating distributor: {e}")
184
+ raise HTTPException(
185
+ status_code=status.HTTP_400_BAD_REQUEST,
186
+ detail=str(e)
187
+ )
188
+ except Exception as e:
189
+ logger.error(f"Error creating distributor: {e}")
190
+ raise HTTPException(
191
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
192
+ detail="Failed to create distributor"
193
+ )
194
+
195
+ @router.put(
196
+ "/{distributor_id}",
197
+ response_model=DistributorOut,
198
+ summary="Update a distributor",
199
+ response_description="Updated distributor details"
200
+ )
201
+ def update_distributor(
202
+ distributor_id: int,
203
+ distributor_in: DistributorCreate,
204
+ db: Session = Depends(get_db)
205
+ ):
206
+ """Update an existing distributor"""
207
+ try:
208
+ distributor_service = DistributorService(db)
209
+ logger.info(f"Updating distributor {distributor_id}: {distributor_in.company_name}")
210
+
211
+ result = distributor_service.update(distributor_id, distributor_in.dict())
212
+ logger.info(f"Successfully updated distributor {distributor_id}: {result.company_name}")
213
+ return result
214
+ except ValueError as e:
215
+ logger.error(f"Validation error updating distributor {distributor_id}: {e}")
216
+ raise HTTPException(
217
+ status_code=status.HTTP_400_BAD_REQUEST,
218
+ detail=str(e)
219
+ )
220
+ except Exception as e:
221
+ logger.error(f"Error updating distributor {distributor_id}: {e}")
222
+ raise HTTPException(
223
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
224
+ detail="Failed to update distributor"
225
+ )
226
+
227
+ @router.delete(
228
+ "/{distributor_id}",
229
+ status_code=status.HTTP_204_NO_CONTENT,
230
+ summary="Delete a distributor",
231
+ response_description="Distributor deleted successfully"
232
+ )
233
+ def delete_distributor(
234
+ distributor_id: int,
235
+ db: Session = Depends(get_db)
236
+ ):
237
+ """Delete a distributor"""
238
+ try:
239
+ distributor_service = DistributorService(db)
240
+ logger.info(f"Deleting distributor {distributor_id}")
241
+
242
+ distributor_service.delete(distributor_id)
243
+ logger.info(f"Successfully deleted distributor {distributor_id}")
244
+ return None
245
+ except Exception as e:
246
+ logger.error(f"Error deleting distributor {distributor_id}: {e}")
247
+ raise HTTPException(
248
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
249
+ detail="Failed to delete distributor"
250
+ )
251
+
252
+ @router.post(
253
+ "/{distributor_id}/activate",
254
+ response_model=DistributorOut,
255
+ summary="Activate a distributor",
256
+ response_description="Activated distributor details"
257
+ )
258
+ def activate_distributor(
259
+ distributor_id: int,
260
+ db: Session = Depends(get_db)
261
+ ):
262
+ """Activate a distributor (set status to active)"""
263
+ try:
264
+ distributor_service = DistributorService(db)
265
+ logger.info(f"Activating distributor {distributor_id}")
266
+
267
+ result = distributor_service.activate(distributor_id)
268
+ logger.info(f"Successfully activated distributor {distributor_id}")
269
+ return result
270
+ except Exception as e:
271
+ logger.error(f"Error activating distributor {distributor_id}: {e}")
272
+ raise HTTPException(
273
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
274
+ detail="Failed to activate distributor"
275
+ )
276
+
277
+ @router.post(
278
+ "/{distributor_id}/deactivate",
279
+ response_model=DistributorOut,
280
+ summary="Deactivate a distributor",
281
+ response_description="Deactivated distributor details"
282
+ )
283
+ def deactivate_distributor(
284
+ distributor_id: int,
285
+ db: Session = Depends(get_db)
286
+ ):
287
+ """Deactivate a distributor (set status to inactive)"""
288
+ try:
289
+ distributor_service = DistributorService(db)
290
+ logger.info(f"Deactivating distributor {distributor_id}")
291
+
292
+ result = distributor_service.deactivate(distributor_id)
293
+ logger.info(f"Successfully deactivated distributor {distributor_id}")
294
+ return result
295
+ except Exception as e:
296
+ logger.error(f"Error deactivating distributor {distributor_id}: {e}")
297
+ raise HTTPException(
298
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
299
+ detail="Failed to deactivate distributor"
300
+ )
app/db/models/distributor.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey
2
+ from app.db.base import Base
3
+
4
+ class Distributor(Base):
5
+ __tablename__ = "Distributors"
6
+
7
+ Id = Column(Integer, primary_key=True, index=True)
8
+ CompanyName = Column(String(200), nullable=False)
9
+ ContactPerson = Column(String(200), nullable=True)
10
+ EmailAddress = Column(String(255), nullable=True)
11
+ Phone = Column(String(50), nullable=True)
12
+ Fax = Column(String(50), nullable=True)
13
+ Address = Column(String(500), nullable=True)
14
+ City = Column(String(100), nullable=True)
15
+ State = Column(String(50), nullable=True)
16
+ ZipCode = Column(String(20), nullable=True)
17
+ Country = Column(String(100), nullable=True)
18
+ Website = Column(String(255), nullable=True)
19
+ Territory = Column(String(100), nullable=True)
20
+ CommissionRate = Column(Float, nullable=True, default=0.0)
21
+ Status = Column(String(20), nullable=False, default='active')
22
+ Notes = Column(String(1000), nullable=True)
23
+ DateCreated = Column(DateTime, nullable=True)
24
+ DateModified = Column(DateTime, nullable=True)
25
+ Enabled = Column(Boolean, nullable=False, default=True)
26
+ EmployeeId = Column(Integer, nullable=True)
app/db/models/reference.py CHANGED
@@ -5,7 +5,8 @@ class State(Base):
5
  __tablename__ = "States"
6
 
7
  state_id = Column("StateID", Integer, primary_key=True, index=True)
8
- state_name = Column("StateName", String(50), nullable=False)
 
9
  state_code = Column("StateCode", String(2), nullable=False)
10
  country = Column("Country", String(50), nullable=True)
11
 
 
5
  __tablename__ = "States"
6
 
7
  state_id = Column("StateID", Integer, primary_key=True, index=True)
8
+ # Renamed from StateName to State as per database schema
9
+ state_name = Column("State", String(50), nullable=False)
10
  state_code = Column("StateCode", String(2), nullable=False)
11
  country = Column("Country", String(50), nullable=True)
12
 
app/db/repositories/distributor_repo.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from sqlalchemy.exc import SQLAlchemyError
3
+ from app.db.models.distributor import Distributor
4
+ from app.schemas.distributor import DistributorCreate, DistributorOut
5
+ from app.schemas.paginated_response import PaginatedResponse
6
+ from app.core.exceptions import NotFoundException
7
+ from typing import List, Optional
8
+ from datetime import datetime, timezone
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class DistributorRepository:
14
+ def __init__(self, db: Session):
15
+ self.db = db
16
+
17
+ def get(self, distributor_id: int) -> Optional[DistributorOut]:
18
+ """Get a single distributor by ID using Distributors table directly."""
19
+ try:
20
+ distributor = self.db.query(Distributor).filter(Distributor.Id == distributor_id).first()
21
+ if distributor:
22
+ return self._map_distributor_data(distributor.__dict__)
23
+ return None
24
+ except SQLAlchemyError as e:
25
+ logger.error(f"Database error getting distributor {distributor_id}: {e}")
26
+ raise
27
+ except Exception as e:
28
+ logger.error(f"Unexpected error getting distributor {distributor_id}: {e}")
29
+ raise
30
+
31
+ def list(self, page: int = 1, page_size: int = 10, order_by: str = "Id", order_direction: str = "ASC") -> PaginatedResponse[DistributorOut]:
32
+ """Get paginated list of all distributors using Distributors table directly with order by."""
33
+ try:
34
+ query = self.db.query(Distributor)
35
+ # Validate order_by field
36
+ if not hasattr(Distributor, order_by):
37
+ order_by = "Id"
38
+ order_col = getattr(Distributor, order_by)
39
+ if order_direction.upper() == "DESC":
40
+ order_col = order_col.desc()
41
+ else:
42
+ order_col = order_col.asc()
43
+ query = query.order_by(order_col)
44
+ total_records = query.count()
45
+ distributors = query.offset((page - 1) * page_size).limit(page_size).all()
46
+ distributor_out_list = [self._map_distributor_data(distributor.__dict__) for distributor in distributors]
47
+ return PaginatedResponse[DistributorOut](
48
+ items=distributor_out_list,
49
+ page=page,
50
+ page_size=page_size,
51
+ total=total_records
52
+ )
53
+ except Exception as e:
54
+ logger.error(f"Error listing distributors: {e}")
55
+ return PaginatedResponse[DistributorOut](
56
+ items=[],
57
+ page=page,
58
+ page_size=page_size,
59
+ total=0
60
+ )
61
+
62
+ def get_by_territory(self, territory: str, page: int = 1, page_size: int = 10,
63
+ order_by: str = "Id", order_dir: str = "ASC") -> PaginatedResponse[DistributorOut]:
64
+ """Get distributors for a specific territory using Distributors table directly."""
65
+ try:
66
+ query = self.db.query(Distributor).filter(Distributor.Territory == territory)
67
+ # Validate order_by field
68
+ if not hasattr(Distributor, order_by):
69
+ order_by = "Id"
70
+ order_col = getattr(Distributor, order_by)
71
+ if order_dir.upper() == "DESC":
72
+ order_col = order_col.desc()
73
+ else:
74
+ order_col = order_col.asc()
75
+ query = query.order_by(order_col)
76
+ total_records = query.count()
77
+ distributors = query.offset((page - 1) * page_size).limit(page_size).all()
78
+ distributor_out_list = [self._map_distributor_data(distributor.__dict__) for distributor in distributors]
79
+ return PaginatedResponse[DistributorOut](
80
+ items=distributor_out_list,
81
+ page=page,
82
+ page_size=page_size,
83
+ total=total_records
84
+ )
85
+ except Exception as e:
86
+ logger.error(f"Error getting distributors for territory {territory}: {e}")
87
+ return PaginatedResponse[DistributorOut](
88
+ items=[],
89
+ page=page,
90
+ page_size=page_size,
91
+ total=0
92
+ )
93
+
94
+ def get_by_status(self, status: str, page: int = 1, page_size: int = 10,
95
+ order_by: str = "Id", order_dir: str = "ASC") -> PaginatedResponse[DistributorOut]:
96
+ """Get distributors by status using Distributors table directly."""
97
+ try:
98
+ query = self.db.query(Distributor).filter(Distributor.Status == status)
99
+ # Validate order_by field
100
+ if not hasattr(Distributor, order_by):
101
+ order_by = "Id"
102
+ order_col = getattr(Distributor, order_by)
103
+ if order_dir.upper() == "DESC":
104
+ order_col = order_col.desc()
105
+ else:
106
+ order_col = order_col.asc()
107
+ query = query.order_by(order_col)
108
+ total_records = query.count()
109
+ distributors = query.offset((page - 1) * page_size).limit(page_size).all()
110
+ distributor_out_list = [self._map_distributor_data(distributor.__dict__) for distributor in distributors]
111
+ return PaginatedResponse[DistributorOut](
112
+ items=distributor_out_list,
113
+ page=page,
114
+ page_size=page_size,
115
+ total=total_records
116
+ )
117
+ except Exception as e:
118
+ logger.error(f"Error getting distributors by status {status}: {e}")
119
+ return PaginatedResponse[DistributorOut](
120
+ items=[],
121
+ page=page,
122
+ page_size=page_size,
123
+ total=0
124
+ )
125
+
126
+ def create(self, distributor_data: DistributorCreate) -> DistributorOut:
127
+ """Create a new distributor using Distributors table directly."""
128
+ try:
129
+ current_time = datetime.now(timezone.utc)
130
+ new_distributor = Distributor(
131
+ CompanyName=distributor_data.company_name,
132
+ ContactPerson=distributor_data.contact_person,
133
+ EmailAddress=distributor_data.email_address,
134
+ Phone=distributor_data.phone,
135
+ Fax=distributor_data.fax,
136
+ Address=distributor_data.address,
137
+ City=distributor_data.city,
138
+ State=distributor_data.state,
139
+ ZipCode=distributor_data.zip_code,
140
+ Country=distributor_data.country,
141
+ Website=distributor_data.website,
142
+ Territory=distributor_data.territory,
143
+ CommissionRate=distributor_data.commission_rate or 0.0,
144
+ Status=distributor_data.status or 'active',
145
+ Notes=distributor_data.notes,
146
+ DateCreated=current_time,
147
+ DateModified=current_time,
148
+ Enabled=distributor_data.enabled or True,
149
+ EmployeeId=distributor_data.employee_id
150
+ )
151
+ self.db.add(new_distributor)
152
+ self.db.commit()
153
+ self.db.refresh(new_distributor)
154
+ return self._map_distributor_data(new_distributor.__dict__)
155
+ except SQLAlchemyError as e:
156
+ logger.error(f"Database error creating distributor: {e}")
157
+ self.db.rollback()
158
+ raise
159
+ except Exception as e:
160
+ logger.error(f"Unexpected error creating distributor: {e}")
161
+ self.db.rollback()
162
+ raise
163
+
164
+ def update(self, distributor_id: int, distributor_data: DistributorCreate) -> DistributorOut:
165
+ """Update a distributor using Distributors table directly."""
166
+ try:
167
+ distributor = self.db.query(Distributor).filter(Distributor.Id == distributor_id).first()
168
+ if not distributor:
169
+ raise NotFoundException("Distributor not found for update")
170
+
171
+ distributor.CompanyName = distributor_data.company_name
172
+ distributor.ContactPerson = distributor_data.contact_person
173
+ distributor.EmailAddress = distributor_data.email_address
174
+ distributor.Phone = distributor_data.phone
175
+ distributor.Fax = distributor_data.fax
176
+ distributor.Address = distributor_data.address
177
+ distributor.City = distributor_data.city
178
+ distributor.State = distributor_data.state
179
+ distributor.ZipCode = distributor_data.zip_code
180
+ distributor.Country = distributor_data.country
181
+ distributor.Website = distributor_data.website
182
+ distributor.Territory = distributor_data.territory
183
+ distributor.CommissionRate = distributor_data.commission_rate
184
+ distributor.Status = distributor_data.status
185
+ distributor.Notes = distributor_data.notes
186
+ distributor.DateModified = datetime.now(timezone.utc)
187
+ distributor.Enabled = distributor_data.enabled
188
+ distributor.EmployeeId = distributor_data.employee_id
189
+
190
+ self.db.commit()
191
+ self.db.refresh(distributor)
192
+ return self._map_distributor_data(distributor.__dict__)
193
+ except SQLAlchemyError as e:
194
+ logger.error(f"Database error updating distributor {distributor_id}: {e}")
195
+ self.db.rollback()
196
+ raise
197
+ except Exception as e:
198
+ logger.error(f"Unexpected error updating distributor {distributor_id}: {e}")
199
+ self.db.rollback()
200
+ raise
201
+
202
+ def delete(self, distributor_id: int) -> bool:
203
+ """Delete a distributor using Distributors table directly."""
204
+ try:
205
+ distributor = self.db.query(Distributor).filter(Distributor.Id == distributor_id).first()
206
+ if not distributor:
207
+ raise NotFoundException("Distributor not found for delete")
208
+ self.db.delete(distributor)
209
+ self.db.commit()
210
+ return True
211
+ except SQLAlchemyError as e:
212
+ logger.error(f"Database error deleting distributor {distributor_id}: {e}")
213
+ self.db.rollback()
214
+ raise
215
+ except Exception as e:
216
+ logger.error(f"Unexpected error deleting distributor {distributor_id}: {e}")
217
+ self.db.rollback()
218
+ raise
219
+
220
+ def get_territories(self) -> List[str]:
221
+ """Get all unique territories."""
222
+ try:
223
+ territories = self.db.query(Distributor.Territory).filter(
224
+ Distributor.Territory.isnot(None),
225
+ Distributor.Territory != ''
226
+ ).distinct().all()
227
+ return [territory[0] for territory in territories]
228
+ except Exception as e:
229
+ logger.error(f"Error getting territories: {e}")
230
+ return []
231
+
232
+ def _map_distributor_data(self, distributor_data: dict) -> DistributorOut:
233
+ """Map database distributor data to DistributorOut schema"""
234
+ return DistributorOut(
235
+ id=distributor_data.get('Id'),
236
+ company_name=distributor_data.get('CompanyName'),
237
+ contact_person=distributor_data.get('ContactPerson'),
238
+ email_address=distributor_data.get('EmailAddress'),
239
+ phone=distributor_data.get('Phone'),
240
+ fax=distributor_data.get('Fax'),
241
+ address=distributor_data.get('Address'),
242
+ city=distributor_data.get('City'),
243
+ state=distributor_data.get('State'),
244
+ zip_code=distributor_data.get('ZipCode'),
245
+ country=distributor_data.get('Country'),
246
+ website=distributor_data.get('Website'),
247
+ territory=distributor_data.get('Territory'),
248
+ commission_rate=distributor_data.get('CommissionRate'),
249
+ status=distributor_data.get('Status'),
250
+ notes=distributor_data.get('Notes'),
251
+ date_created=distributor_data.get('DateCreated'),
252
+ date_modified=distributor_data.get('DateModified'),
253
+ enabled=distributor_data.get('Enabled'),
254
+ employee_id=distributor_data.get('EmployeeId')
255
+ )
app/db/repositories/reference_repo.py CHANGED
@@ -48,13 +48,26 @@ class ReferenceDataRepository:
48
  result = self.db.execute(sp_query)
49
 
50
  if result.returns_rows:
51
- states_data = [dict(row._mapping) for row in result.fetchall()]
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  self._cache[cache_key] = states_data
53
  logger.info(f"Retrieved {len(states_data)} states via stored procedure")
54
  return states_data
55
 
56
  except Exception as e:
57
- logger.warning(f"Stored procedure failed, using fallback: {e}")
58
 
59
  # Fallback to direct query
60
  query = self.db.query(State)
@@ -66,7 +79,7 @@ class ReferenceDataRepository:
66
  states_data = [
67
  {
68
  'state_id': state.state_id,
69
- 'state_name': state.state_name,
70
  'state_code': state.state_code,
71
  'country': state.country
72
  }
 
48
  result = self.db.execute(sp_query)
49
 
50
  if result.returns_rows:
51
+ # Map the stored procedure results to our expected schema
52
+ states_data = []
53
+ for row in result.fetchall():
54
+ row_dict = dict(row._mapping)
55
+ # Adjust for potential naming differences in stored procedure output
56
+ # Map the SP's column names to our expected schema
57
+ state_data = {
58
+ 'state_id': row_dict.get('StateID', None),
59
+ 'state_name': row_dict.get('State', row_dict.get('StateName', None)),
60
+ 'state_code': row_dict.get('StateCode', None),
61
+ 'country': row_dict.get('Country', None)
62
+ }
63
+ states_data.append(state_data)
64
+
65
  self._cache[cache_key] = states_data
66
  logger.info(f"Retrieved {len(states_data)} states via stored procedure")
67
  return states_data
68
 
69
  except Exception as e:
70
+ logger.warning(f"Stored procedure failed, using fallback: {e}", exc_info=True)
71
 
72
  # Fallback to direct query
73
  query = self.db.query(State)
 
79
  states_data = [
80
  {
81
  'state_id': state.state_id,
82
+ 'state_name': state.state_name, # This will now correctly map from the 'State' column
83
  'state_code': state.state_code,
84
  'country': state.country
85
  }
app/schemas/distributor.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+ class DistributorCreate(BaseModel):
6
+ company_name: str = Field(..., description="Company name")
7
+ contact_person: Optional[str] = Field(None, description="Contact person name")
8
+ email_address: Optional[str] = Field(None, description="Email address")
9
+ phone: Optional[str] = Field(None, description="Phone number")
10
+ fax: Optional[str] = Field(None, description="Fax number")
11
+ address: Optional[str] = Field(None, description="Street address")
12
+ city: Optional[str] = Field(None, description="City")
13
+ state: Optional[str] = Field(None, description="State/Province")
14
+ zip_code: Optional[str] = Field(None, description="ZIP/Postal code")
15
+ country: Optional[str] = Field(None, description="Country")
16
+ website: Optional[str] = Field(None, description="Website URL")
17
+ territory: Optional[str] = Field(None, description="Territory/Region")
18
+ commission_rate: Optional[float] = Field(0.0, description="Commission rate")
19
+ status: Optional[str] = Field("active", description="Distributor status")
20
+ notes: Optional[str] = Field(None, description="Additional notes")
21
+ enabled: Optional[bool] = Field(True, description="Enabled status")
22
+ employee_id: Optional[int] = Field(None, description="Employee ID")
23
+
24
+ class DistributorOut(BaseModel):
25
+ id: int = Field(..., description="Distributor ID")
26
+ company_name: str = Field(..., description="Company name")
27
+ contact_person: Optional[str] = Field(None, description="Contact person name")
28
+ email_address: Optional[str] = Field(None, description="Email address")
29
+ phone: Optional[str] = Field(None, description="Phone number")
30
+ fax: Optional[str] = Field(None, description="Fax number")
31
+ address: Optional[str] = Field(None, description="Street address")
32
+ city: Optional[str] = Field(None, description="City")
33
+ state: Optional[str] = Field(None, description="State/Province")
34
+ zip_code: Optional[str] = Field(None, description="ZIP/Postal code")
35
+ country: Optional[str] = Field(None, description="Country")
36
+ website: Optional[str] = Field(None, description="Website URL")
37
+ territory: Optional[str] = Field(None, description="Territory/Region")
38
+ commission_rate: Optional[float] = Field(None, description="Commission rate")
39
+ status: Optional[str] = Field(None, description="Distributor status")
40
+ notes: Optional[str] = Field(None, description="Additional notes")
41
+ date_created: Optional[datetime] = Field(None, description="Date created")
42
+ date_modified: Optional[datetime] = Field(None, description="Date modified")
43
+ enabled: Optional[bool] = Field(None, description="Enabled status")
44
+ employee_id: Optional[int] = Field(None, description="Employee ID")
45
+
46
+ class Config:
47
+ from_attributes = True # Updated for Pydantic v2
app/services/distributor_service.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from app.db.repositories.distributor_repo import DistributorRepository
3
+ from app.schemas.distributor import DistributorCreate, DistributorOut
4
+ from app.schemas.paginated_response import PaginatedResponse
5
+ from app.core.exceptions import NotFoundException
6
+ from typing import List, Optional
7
+
8
+ # Constants
9
+ DISTRIBUTOR_NOT_FOUND_MSG = "Distributor not found"
10
+
11
+ class DistributorService:
12
+ def __init__(self, db: Session):
13
+ self.repo = DistributorRepository(db)
14
+
15
+ def get(self, distributor_id: int) -> DistributorOut:
16
+ """Get a single distributor by ID"""
17
+ distributor = self.repo.get(distributor_id)
18
+ if not distributor:
19
+ raise NotFoundException(DISTRIBUTOR_NOT_FOUND_MSG)
20
+ return distributor
21
+
22
+ def list(self, page: int = 1, page_size: int = 10, order_by: str = "Id",
23
+ order_direction: str = "ASC") -> PaginatedResponse[DistributorOut]:
24
+ """Get a paginated list of all distributors"""
25
+ return self.repo.list(page, page_size, order_by, order_direction)
26
+
27
+ def get_by_territory(self, territory: str, page: int = 1, page_size: int = 10,
28
+ order_by: str = "Id", order_dir: str = "ASC") -> PaginatedResponse[DistributorOut]:
29
+ """Get all distributors for a specific territory"""
30
+ return self.repo.get_by_territory(territory, page, page_size, order_by, order_dir)
31
+
32
+ def get_by_status(self, status: str, page: int = 1, page_size: int = 10,
33
+ order_by: str = "Id", order_dir: str = "ASC") -> PaginatedResponse[DistributorOut]:
34
+ """Get all distributors by status"""
35
+ return self.repo.get_by_status(status, page, page_size, order_by, order_dir)
36
+
37
+ def create(self, data: dict) -> DistributorOut:
38
+ """Create a new distributor"""
39
+ # Validate and clean data
40
+ validated_data = self._validate_distributor_data(data)
41
+
42
+ # Convert dict to DistributorCreate for validation
43
+ distributor_data = DistributorCreate(**validated_data)
44
+
45
+ return self.repo.create(distributor_data)
46
+
47
+ def update(self, distributor_id: int, data: dict) -> DistributorOut:
48
+ """Update an existing distributor"""
49
+ # Check if distributor exists
50
+ existing_distributor = self.repo.get(distributor_id)
51
+ if not existing_distributor:
52
+ raise NotFoundException(DISTRIBUTOR_NOT_FOUND_MSG)
53
+
54
+ # Validate and clean data
55
+ validated_data = self._validate_distributor_data(data)
56
+
57
+ # Convert dict to DistributorCreate for validation
58
+ distributor_data = DistributorCreate(**validated_data)
59
+
60
+ return self.repo.update(distributor_id, distributor_data)
61
+
62
+ def delete(self, distributor_id: int):
63
+ """Delete a distributor"""
64
+ # Check if distributor exists
65
+ existing_distributor = self.repo.get(distributor_id)
66
+ if not existing_distributor:
67
+ raise NotFoundException(DISTRIBUTOR_NOT_FOUND_MSG)
68
+
69
+ self.repo.delete(distributor_id)
70
+
71
+ def get_territories(self) -> List[str]:
72
+ """Get all unique territories"""
73
+ return self.repo.get_territories()
74
+
75
+ def activate(self, distributor_id: int) -> DistributorOut:
76
+ """Activate a distributor"""
77
+ # Check if distributor exists
78
+ existing_distributor = self.repo.get(distributor_id)
79
+ if not existing_distributor:
80
+ raise NotFoundException(DISTRIBUTOR_NOT_FOUND_MSG)
81
+
82
+ # Update status to active
83
+ distributor_data = DistributorCreate(**{
84
+ **existing_distributor.dict(),
85
+ 'status': 'active'
86
+ })
87
+
88
+ return self.repo.update(distributor_id, distributor_data)
89
+
90
+ def deactivate(self, distributor_id: int) -> DistributorOut:
91
+ """Deactivate a distributor"""
92
+ # Check if distributor exists
93
+ existing_distributor = self.repo.get(distributor_id)
94
+ if not existing_distributor:
95
+ raise NotFoundException(DISTRIBUTOR_NOT_FOUND_MSG)
96
+
97
+ # Update status to inactive
98
+ distributor_data = DistributorCreate(**{
99
+ **existing_distributor.dict(),
100
+ 'status': 'inactive'
101
+ })
102
+
103
+ return self.repo.update(distributor_id, distributor_data)
104
+
105
+ def _validate_distributor_data(self, data: dict) -> dict:
106
+ """Internal method to validate and clean distributor data"""
107
+ # Ensure company name is provided (required)
108
+ if not data.get('company_name'):
109
+ raise ValueError("Company name is required")
110
+
111
+ # Clean and validate email
112
+ self._validate_email(data)
113
+
114
+ # Clean phone numbers
115
+ self._clean_phone_fields(data)
116
+
117
+ # Validate commission rate
118
+ self._validate_commission_rate(data)
119
+
120
+ # Validate status
121
+ self._validate_status(data)
122
+
123
+ # Clean website URL
124
+ self._clean_website_url(data)
125
+
126
+ return data
127
+
128
+ def _validate_email(self, data: dict):
129
+ """Validate and clean email address"""
130
+ if data.get('email_address'):
131
+ email = data['email_address'].strip().lower()
132
+ if '@' not in email:
133
+ raise ValueError("Invalid email address format")
134
+ data['email_address'] = email
135
+
136
+ def _clean_phone_fields(self, data: dict):
137
+ """Clean phone number fields"""
138
+ phone_fields = ['phone', 'fax']
139
+ for field in phone_fields:
140
+ if data.get(field):
141
+ cleaned_phone = ''.join(c for c in data[field] if c.isdigit() or c in '+()-. ')
142
+ data[field] = cleaned_phone.strip()
143
+
144
+ def _validate_commission_rate(self, data: dict):
145
+ """Validate commission rate"""
146
+ if data.get('commission_rate') is not None and data.get('commission_rate') < 0:
147
+ raise ValueError("Commission rate must be a positive number")
148
+
149
+ def _validate_status(self, data: dict):
150
+ """Validate status field"""
151
+ valid_statuses = ['active', 'inactive', 'pending']
152
+ if data.get('status') and data.get('status') not in valid_statuses:
153
+ raise ValueError(f"Status must be one of: {', '.join(valid_statuses)}")
154
+
155
+ def _clean_website_url(self, data: dict):
156
+ """Clean and format website URL"""
157
+ if data.get('website'):
158
+ website = data['website'].strip()
159
+ if website and not website.startswith(('http://', 'https://')):
160
+ website = 'https://' + website
161
+ data['website'] = website