MukeshKapoor25 commited on
Commit
f5b378b
·
1 Parent(s): 1326afb

feat(customers): add endpoint to retrieve projects associated with a specific customer with pagination

Browse files
app/controllers/customers.py CHANGED
@@ -5,9 +5,11 @@ from app.services.customer_service import CustomerService
5
  from app.services.customer_list_service import CustomerListService
6
  from app.services.contact_service import ContactService
7
  from app.services.address_service import AddressService
 
8
  from app.schemas.customer import CustomerCreate, CustomerOut, CustomerUpdate
9
  from app.schemas.contact import ContactCreate, ContactOut
10
  from app.schemas.address import AddressCreate, AddressOut
 
11
  from app.schemas.paginated_response import PaginatedResponse
12
  from typing import List, Optional
13
  import logging
@@ -391,4 +393,24 @@ def delete_customer_contact(
391
  detail="Failed to delete contact"
392
  )
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
 
5
  from app.services.customer_list_service import CustomerListService
6
  from app.services.contact_service import ContactService
7
  from app.services.address_service import AddressService
8
+ from app.services.project_service import ProjectService
9
  from app.schemas.customer import CustomerCreate, CustomerOut, CustomerUpdate
10
  from app.schemas.contact import ContactCreate, ContactOut
11
  from app.schemas.address import AddressCreate, AddressOut
12
+ from app.schemas.project_detail import ProjectCustomerOut
13
  from app.schemas.paginated_response import PaginatedResponse
14
  from typing import List, Optional
15
  import logging
 
393
  detail="Failed to delete contact"
394
  )
395
 
396
+ # Project-related endpoints
397
+ @router.get("/{customer_id}/projects", response_model=List[ProjectCustomerOut])
398
+ def get_customer_projects(
399
+ customer_id: str,
400
+ page: Optional[int] = Query(1, description="Page number (1-indexed)", ge=1),
401
+ page_size: Optional[int] = Query(100, description="Number of records per page", ge=1, le=1000),
402
+ db: Session = Depends(get_db)
403
+ ):
404
+ """Get all projects associated with a specific customer
405
+
406
+ Returns a paginated list of all project-bidder relationships for the specified
407
+ customer ID, including complete details (barrier sizes, contacts, notes) for each.
408
+
409
+ - **customer_id**: The customer ID (CustId from Bidders table)
410
+ - **page**: Page number starting from 1
411
+ - **page_size**: Number of records per page (max 1000)
412
+ """
413
+ service = ProjectService(db)
414
+ return service.get_customer_projects(customer_id, page=page, page_size=page_size)
415
+
416
 
app/db/repositories/bidder_repo.py CHANGED
@@ -156,6 +156,54 @@ class BidderRepository:
156
  logger.warning(f"Error fetching bidder for project {project_no}, customer {customer_id}: {e}")
157
  return None
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  def get_bidder_barrier_sizes_raw(self, bidder_id: int):
160
  """Return raw barrier size rows for a bidder (list of dicts)."""
161
  try:
 
156
  logger.warning(f"Error fetching bidder for project {project_no}, customer {customer_id}: {e}")
157
  return None
158
 
159
+ def fetch_bidders_by_customer_id_raw(self, customer_id: str, page: int = 1, page_size: int = 100):
160
+ """Fetch all bidders (project associations) for a specific customer ID with pagination.
161
+
162
+ Returns a list of dicts representing all projects the customer is associated with.
163
+ """
164
+ try:
165
+ with self.db.get_bind().connect() as conn:
166
+ bidders_query = text("""
167
+ SELECT
168
+ ProjNo as proj_no,
169
+ CustId as cust_id,
170
+ Quote as quote,
171
+ Contact as contact,
172
+ Phone as phone,
173
+ Notes as notes,
174
+ DateLastContact as date_last_contact,
175
+ DateFollowup as date_followup,
176
+ [Primary] as is_primary,
177
+ CustType as cust_type,
178
+ EmailAddress as email_address,
179
+ Id,
180
+ Fax as fax,
181
+ OrderNr as order_nr,
182
+ CustomerPO as customer_po,
183
+ ShipDate as ship_date,
184
+ DeliverDate as deliver_date,
185
+ ReplacementCost as replacement_cost,
186
+ QuoteDate as quote_date,
187
+ InvoiceDate as invoice_date,
188
+ LessPayment as less_payment,
189
+ Enabled as enabled,
190
+ EmployeeId as employee_id
191
+ FROM Bidders
192
+ WHERE CustId = :customer_id
193
+ ORDER BY ProjNo DESC, Id
194
+ OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY
195
+ """)
196
+
197
+ offset = max(0, (page - 1) * page_size)
198
+ limit = max(1, min(page_size, 1000))
199
+ result = conn.execute(bidders_query, {"customer_id": customer_id, "offset": offset, "limit": limit})
200
+ rows = result.fetchall()
201
+ columns = result.keys()
202
+ return [dict(zip(columns, row)) for row in rows]
203
+ except Exception as e:
204
+ logger.warning(f"Error fetching bidders for customer {customer_id}: {e}")
205
+ return []
206
+
207
  def get_bidder_barrier_sizes_raw(self, bidder_id: int):
208
  """Return raw barrier size rows for a bidder (list of dicts)."""
209
  try:
app/services/project_service.py CHANGED
@@ -239,6 +239,122 @@ class ProjectService:
239
 
240
  return customer
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  def list_projects(self, customer_type: int = 0, order_by: str = "project_no",
243
  order_direction: str = "asc", page: int = 1, page_size: int = 10) -> PaginatedResponse[ProjectOut]:
244
  """
 
239
 
240
  return customer
241
 
242
+ def get_customer_projects(self, customer_id: str, page: int = 1, page_size: int = 100) -> List[ProjectCustomerOut]:
243
+ """
244
+ Get all projects associated with a specific customer.
245
+
246
+ Args:
247
+ customer_id: The customer ID (CustId)
248
+ page: Page number for pagination
249
+ page_size: Number of records per page
250
+
251
+ Returns:
252
+ List[ProjectCustomerOut]: List of project-bidder relationships with full details
253
+ """
254
+ from app.db.repositories.bidder_repo import BidderRepository
255
+
256
+ bidder_repo = BidderRepository(self.db)
257
+ bidder_rows = bidder_repo.fetch_bidders_by_customer_id_raw(customer_id, page=page, page_size=page_size)
258
+
259
+ logger.info(f"Repository returned {len(bidder_rows)} bidders for customer {customer_id}")
260
+
261
+ customers = []
262
+ for bidder_data in bidder_rows:
263
+ bidder_id = bidder_data.get('Id')
264
+
265
+ # Fetch related data
266
+ barrier_rows = bidder_repo.get_bidder_barrier_sizes_raw(bidder_id)
267
+ contacts_rows = bidder_repo.get_bidder_contacts_raw(bidder_id)
268
+ notes_rows = bidder_repo.get_bidder_notes_raw(bidder_id)
269
+
270
+ # Map barrier rows to BarrierSizeOut
271
+ barrier_sizes = []
272
+ for br in barrier_rows:
273
+ barrier_sizes.append(BarrierSizeOut(
274
+ id=br.get('Id', 0),
275
+ inventory_id=str(br.get('InventoryId', '')) if br.get('InventoryId') is not None else None,
276
+ bidder_id=bidder_id,
277
+ barrier_size_id=br.get('BarrierSizeId', 0),
278
+ install_advisor_fees=br.get('InstallAdvisorFees'),
279
+ is_standard=bool(br.get('IsStandard', True)),
280
+ width=br.get('Width'),
281
+ length=br.get('Length'),
282
+ cable_units=br.get('CableUnits'),
283
+ height=br.get('Height'),
284
+ price=br.get('Price')
285
+ ))
286
+
287
+ # Map contacts
288
+ contacts = []
289
+ for cr in contacts_rows:
290
+ contacts.append(ContactOut(
291
+ id=cr.get('Id'),
292
+ contact_id=cr.get('ContactId'),
293
+ bidder_id=bidder_id,
294
+ enabled=bool(cr.get('Enabled', True)),
295
+ first_name=cr.get('FirstName'),
296
+ last_name=cr.get('LastName'),
297
+ title=cr.get('Title'),
298
+ email=cr.get('EmailAddress'),
299
+ phones=[],
300
+ phone1=cr.get('WorkPhone'),
301
+ phone2=cr.get('MobilePhone')
302
+ ))
303
+
304
+ # Map notes
305
+ bidder_notes = []
306
+ for nr in notes_rows:
307
+ bidder_notes.append(BidderNoteOut(
308
+ id=nr.get('Id'),
309
+ bidder_id=bidder_id,
310
+ date=nr.get('Date'),
311
+ employee_id=nr.get('EmployeeID'),
312
+ notes=nr.get('Notes', '')
313
+ ))
314
+
315
+ # Create customer object with proper type conversions
316
+ replacement_cost = bidder_data.get('replacement_cost')
317
+ if replacement_cost == '' or replacement_cost is None:
318
+ replacement_cost = None
319
+
320
+ cust_type = bidder_data.get('cust_type')
321
+ if cust_type is not None:
322
+ cust_type = str(cust_type)
323
+
324
+ customer = ProjectCustomerOut(
325
+ proj_no=bidder_data.get('proj_no', 0),
326
+ cust_id=str(bidder_data.get('cust_id', '')),
327
+ quote=bidder_data.get('quote'),
328
+ contact=bidder_data.get('contact'),
329
+ phone=bidder_data.get('phone'),
330
+ notes=bidder_data.get('notes'),
331
+ date_last_contact=bidder_data.get('date_last_contact'),
332
+ date_followup=bidder_data.get('date_followup'),
333
+ primary=bool(bidder_data.get('is_primary', False)),
334
+ cust_type=cust_type,
335
+ email_address=bidder_data.get('email_address'),
336
+ id=bidder_id,
337
+ fax=bidder_data.get('fax'),
338
+ order_nr=bidder_data.get('order_nr'),
339
+ customer_po=bidder_data.get('customer_po'),
340
+ ship_date=bidder_data.get('ship_date'),
341
+ deliver_date=bidder_data.get('deliver_date'),
342
+ replacement_cost=replacement_cost,
343
+ quote_date=bidder_data.get('quote_date'),
344
+ invoice_date=bidder_data.get('invoice_date'),
345
+ less_payment=bidder_data.get('less_payment'),
346
+ barrier_sizes=barrier_sizes,
347
+ contacts=contacts,
348
+ bidder_notes=bidder_notes,
349
+ bid_date=bidder_data.get('date_last_contact'), # Using last contact as bid date
350
+ enabled=bool(bidder_data.get('enabled', True)),
351
+ employee_id=str(bidder_data.get('employee_id')) if bidder_data.get('employee_id') is not None else None
352
+ )
353
+
354
+ customers.append(customer)
355
+
356
+ return customers
357
+
358
  def list_projects(self, customer_type: int = 0, order_by: str = "project_no",
359
  order_direction: str = "asc", page: int = 1, page_size: int = 10) -> PaginatedResponse[ProjectOut]:
360
  """
app/tests/unit/test_project_customers.py CHANGED
@@ -5,6 +5,7 @@ from app.schemas.project_detail import ProjectCustomerOut, BarrierSizeOut, Conta
5
 
6
  from unittest.mock import MagicMock
7
  import app.controllers.projects as projects_controller
 
8
 
9
 
10
  def make_sample_bidder_row():
@@ -187,4 +188,123 @@ def test_get_project_customer_detail_endpoint(monkeypatch):
187
  assert result.cust_id == 'CUST777'
188
  assert result.id == 77
189
  assert result.order_nr == 'ORD123'
190
- assert result.employee_id == 'EMP5'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  from unittest.mock import MagicMock
7
  import app.controllers.projects as projects_controller
8
+ import app.controllers.customers as customers_controller
9
 
10
 
11
  def make_sample_bidder_row():
 
188
  assert result.cust_id == 'CUST777'
189
  assert result.id == 77
190
  assert result.order_nr == 'ORD123'
191
+ assert result.employee_id == 'EMP5'
192
+
193
+
194
+ def test_get_customer_projects_service(monkeypatch):
195
+ """Test ProjectService.get_customer_projects method"""
196
+ mock_db = Mock()
197
+ service = ProjectService(mock_db)
198
+
199
+ # Create sample bidders for multiple projects
200
+ bidder1 = make_sample_bidder_row()
201
+ bidder1['proj_no'] = 100
202
+ bidder1['cust_id'] = 'CUST888'
203
+ bidder1['Id'] = 20
204
+
205
+ bidder2 = make_sample_bidder_row()
206
+ bidder2['proj_no'] = 200
207
+ bidder2['cust_id'] = 'CUST888'
208
+ bidder2['Id'] = 21
209
+
210
+ # Mock BidderRepository methods
211
+ from app.db.repositories.bidder_repo import BidderRepository
212
+ mock_bidder_repo = Mock(spec=BidderRepository)
213
+ mock_bidder_repo.fetch_bidders_by_customer_id_raw = Mock(return_value=[bidder1, bidder2])
214
+ mock_bidder_repo.get_bidder_barrier_sizes_raw = Mock(return_value=[])
215
+ mock_bidder_repo.get_bidder_contacts_raw = Mock(return_value=[])
216
+ mock_bidder_repo.get_bidder_notes_raw = Mock(return_value=[])
217
+
218
+ # Patch BidderRepository instantiation
219
+ def fake_bidder_repo_init(db):
220
+ return mock_bidder_repo
221
+
222
+ monkeypatch.setattr('app.db.repositories.bidder_repo.BidderRepository', fake_bidder_repo_init)
223
+
224
+ projects = service.get_customer_projects('CUST888', page=1, page_size=10)
225
+
226
+ assert isinstance(projects, list)
227
+ assert len(projects) == 2
228
+ assert projects[0].proj_no == 100
229
+ assert projects[0].cust_id == 'CUST888'
230
+ assert projects[1].proj_no == 200
231
+ assert projects[1].cust_id == 'CUST888'
232
+ mock_bidder_repo.fetch_bidders_by_customer_id_raw.assert_called_once_with('CUST888', page=1, page_size=10)
233
+
234
+
235
+ def test_get_customer_projects_endpoint(monkeypatch):
236
+ """Test controller endpoint for customer projects"""
237
+ sample1 = ProjectCustomerOut(
238
+ proj_no=300,
239
+ cust_id='CUST555',
240
+ quote=None,
241
+ contact='Project A Contact',
242
+ phone='111',
243
+ notes=None,
244
+ date_last_contact=None,
245
+ date_followup=None,
246
+ primary=True,
247
+ cust_type='1',
248
+ email_address='a@example.com',
249
+ id=30,
250
+ fax=None,
251
+ order_nr=None,
252
+ customer_po=None,
253
+ ship_date=None,
254
+ deliver_date=None,
255
+ replacement_cost=None,
256
+ quote_date=None,
257
+ invoice_date=None,
258
+ less_payment=None,
259
+ barrier_sizes=[],
260
+ contacts=[],
261
+ bidder_notes=[],
262
+ bid_date=None,
263
+ enabled=True,
264
+ employee_id=None
265
+ )
266
+
267
+ sample2 = ProjectCustomerOut(
268
+ proj_no=400,
269
+ cust_id='CUST555',
270
+ quote=None,
271
+ contact='Project B Contact',
272
+ phone='222',
273
+ notes=None,
274
+ date_last_contact=None,
275
+ date_followup=None,
276
+ primary=False,
277
+ cust_type='2',
278
+ email_address='b@example.com',
279
+ id=31,
280
+ fax=None,
281
+ order_nr=None,
282
+ customer_po=None,
283
+ ship_date=None,
284
+ deliver_date=None,
285
+ replacement_cost=None,
286
+ quote_date=None,
287
+ invoice_date=None,
288
+ less_payment=None,
289
+ barrier_sizes=[],
290
+ contacts=[],
291
+ bidder_notes=[],
292
+ bid_date=None,
293
+ enabled=True,
294
+ employee_id=None
295
+ )
296
+
297
+ def fake_get_customer_projects(self, customer_id, page=1, page_size=100):
298
+ return [sample1, sample2]
299
+
300
+ monkeypatch.setattr('app.services.project_service.ProjectService.get_customer_projects', fake_get_customer_projects)
301
+
302
+ fake_db = MagicMock()
303
+ result = customers_controller.get_customer_projects('CUST555', page=1, page_size=10, db=fake_db)
304
+
305
+ assert isinstance(result, list)
306
+ assert len(result) == 2
307
+ assert result[0].proj_no == 300
308
+ assert result[0].cust_id == 'CUST555'
309
+ assert result[1].proj_no == 400
310
+ assert result[1].cust_id == 'CUST555'