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

feat(projects): add endpoints to retrieve project customers and customer details with pagination

Browse files
app/controllers/projects.py CHANGED
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
3
  from app.db.session import get_db
4
  from app.services.project_service import ProjectService
5
  from app.schemas.project import ProjectCreate, ProjectOut
6
- from app.schemas.project_detail import ProjectDetailOut
7
  from app.schemas.paginated_response import PaginatedResponse
8
  from typing import List, Optional
9
 
@@ -42,6 +42,41 @@ def get_project(project_no: int, db: Session = Depends(get_db)):
42
  service = ProjectService(db)
43
  return service.get_detailed(project_no)
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  @router.post("/", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
46
  def create_project(project_in: ProjectCreate, db: Session = Depends(get_db)):
47
  """
 
3
  from app.db.session import get_db
4
  from app.services.project_service import ProjectService
5
  from app.schemas.project import ProjectCreate, ProjectOut
6
+ from app.schemas.project_detail import ProjectDetailOut, ProjectCustomerOut
7
  from app.schemas.paginated_response import PaginatedResponse
8
  from typing import List, Optional
9
 
 
42
  service = ProjectService(db)
43
  return service.get_detailed(project_no)
44
 
45
+
46
+ @router.get("/{project_no}/customers", response_model=List[ProjectCustomerOut])
47
+ def get_project_customers(
48
+ project_no: int,
49
+ page: Optional[int] = Query(1, description="Page number (1-indexed)", ge=1),
50
+ page_size: Optional[int] = Query(100, description="Number of records per page", ge=1, le=1000),
51
+ db: Session = Depends(get_db)
52
+ ):
53
+ """Get customers associated with a specific project (by ProjectNo)
54
+
55
+ Returns a paginated list of customers for the project. Pagination defaults
56
+ to page=1 and page_size=100 to avoid very large responses.
57
+ """
58
+ service = ProjectService(db)
59
+ # Use the dedicated service method to fetch only customers for the project
60
+ return service.get_customers(project_no, page=page, page_size=page_size)
61
+
62
+
63
+ @router.get("/{project_no}/customers/{customer_id}", response_model=ProjectCustomerOut)
64
+ def get_project_customer_detail(
65
+ project_no: int,
66
+ customer_id: str,
67
+ db: Session = Depends(get_db)
68
+ ):
69
+ """Get detailed bidder information for a specific customer on a project
70
+
71
+ Returns the complete bidder details including barrier sizes, contacts, and notes
72
+ for the specified project number and customer ID combination.
73
+
74
+ - **project_no**: The project number
75
+ - **customer_id**: The customer ID (CustId from Bidders table)
76
+ """
77
+ service = ProjectService(db)
78
+ return service.get_project_customer_detail(project_no, customer_id)
79
+
80
  @router.post("/", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
81
  def create_project(project_in: ProjectCreate, db: Session = Depends(get_db)):
82
  """
app/db/repositories/bidder_repo.py CHANGED
@@ -6,6 +6,7 @@ from app.schemas.paginated_response import PaginatedResponse
6
  from app.core.exceptions import NotFoundException
7
  from typing import List, Optional
8
  import logging
 
9
 
10
  logger = logging.getLogger(__name__)
11
 
@@ -58,6 +59,168 @@ class BidderRepository:
58
  total=0
59
  )
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def get_by_project_no(self, proj_no: str, page: int = 1, page_size: int = 10,
62
  order_by: str = "Id", order_dir: str = "ASC") -> PaginatedResponse[BidderOut]:
63
  """Get bidders for a specific project using Bidders table directly."""
 
6
  from app.core.exceptions import NotFoundException
7
  from typing import List, Optional
8
  import logging
9
+ from sqlalchemy import text
10
 
11
  logger = logging.getLogger(__name__)
12
 
 
59
  total=0
60
  )
61
 
62
+ # --- Raw SQL helpers for project-related customer data ---
63
+ def fetch_project_bidders_raw(self, project_no: int, page: int = 1, page_size: int = 100):
64
+ """Return raw bidder rows for a given project using direct SQL (dicts).
65
+
66
+ This method is intended to be used by higher-level services that will
67
+ map database column names to schema fields.
68
+ """
69
+ try:
70
+ with self.db.get_bind().connect() as conn:
71
+ bidders_query = text(f"""
72
+ SELECT
73
+ ProjNo as proj_no,
74
+ CustId as cust_id,
75
+ Quote as quote,
76
+ Contact as contact,
77
+ Phone as phone,
78
+ Notes as notes,
79
+ DateLastContact as date_last_contact,
80
+ DateFollowup as date_followup,
81
+ [Primary] as is_primary,
82
+ CustType as cust_type,
83
+ EmailAddress as email_address,
84
+ Id,
85
+ Fax as fax,
86
+ OrderNr as order_nr,
87
+ CustomerPO as customer_po,
88
+ ShipDate as ship_date,
89
+ DeliverDate as deliver_date,
90
+ ReplacementCost as replacement_cost,
91
+ QuoteDate as quote_date,
92
+ InvoiceDate as invoice_date,
93
+ LessPayment as less_payment,
94
+ Enabled as enabled,
95
+ EmployeeId as employee_id
96
+ FROM Bidders
97
+ WHERE ProjNo = :project_no
98
+ ORDER BY [Primary] DESC, Id
99
+ OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY
100
+ """,
101
+ )
102
+
103
+ offset = max(0, (page - 1) * page_size)
104
+ limit = max(1, min(page_size, 1000))
105
+ result = conn.execute(bidders_query, {"project_no": project_no, "offset": offset, "limit": limit})
106
+ rows = result.fetchall()
107
+ columns = result.keys()
108
+ return [dict(zip(columns, row)) for row in rows]
109
+ except Exception as e:
110
+ logger.warning(f"Error fetching project bidders raw for project {project_no}: {e}")
111
+ return []
112
+
113
+ def fetch_project_bidder_by_customer_id_raw(self, project_no: int, customer_id: str):
114
+ """Fetch a single bidder for a specific project and customer ID.
115
+
116
+ Returns a single dict or None if not found.
117
+ """
118
+ try:
119
+ with self.db.get_bind().connect() as conn:
120
+ bidder_query = text("""
121
+ SELECT
122
+ ProjNo as proj_no,
123
+ CustId as cust_id,
124
+ Quote as quote,
125
+ Contact as contact,
126
+ Phone as phone,
127
+ Notes as notes,
128
+ DateLastContact as date_last_contact,
129
+ DateFollowup as date_followup,
130
+ [Primary] as is_primary,
131
+ CustType as cust_type,
132
+ EmailAddress as email_address,
133
+ Id,
134
+ Fax as fax,
135
+ OrderNr as order_nr,
136
+ CustomerPO as customer_po,
137
+ ShipDate as ship_date,
138
+ DeliverDate as deliver_date,
139
+ ReplacementCost as replacement_cost,
140
+ QuoteDate as quote_date,
141
+ InvoiceDate as invoice_date,
142
+ LessPayment as less_payment,
143
+ Enabled as enabled,
144
+ EmployeeId as employee_id
145
+ FROM Bidders
146
+ WHERE ProjNo = :project_no AND CustId = :customer_id
147
+ """)
148
+
149
+ result = conn.execute(bidder_query, {"project_no": project_no, "customer_id": customer_id})
150
+ row = result.fetchone()
151
+ if row:
152
+ columns = result.keys()
153
+ return dict(zip(columns, row))
154
+ return None
155
+ except Exception as e:
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:
162
+ with self.db.get_bind().connect() as conn:
163
+ direct_query = text("""
164
+ SELECT Id, InventoryId, BarrierSizeId, InstallAdvisorFees,
165
+ IsStandard, Width, Length, CableUnits, Height, Price
166
+ FROM BiddersBarrierSizes
167
+ WHERE BidderId = :bidder_id
168
+ ORDER BY Id
169
+ """)
170
+ result = conn.execute(direct_query, {"bidder_id": bidder_id})
171
+ if result.returns_rows:
172
+ rows = result.fetchall()
173
+ cols = result.keys()
174
+ return [dict(zip(cols, r)) for r in rows]
175
+ return []
176
+ except Exception as e:
177
+ logger.warning(f"Error fetching barrier sizes for bidder {bidder_id}: {e}")
178
+ return []
179
+
180
+ def get_bidder_contacts_raw(self, bidder_id: int):
181
+ """Return raw contact rows for a bidder."""
182
+ try:
183
+ with self.db.get_bind().connect() as conn:
184
+ contacts_query = text("""
185
+ SELECT bc.Id, bc.ContactId, bc.BidderId, bc.Enabled,
186
+ c.FirstName, c.LastName, c.Title, c.EmailAddress,
187
+ c.WorkPhone, c.MobilePhone
188
+ FROM BidderContact bc
189
+ INNER JOIN Contacts c ON bc.ContactId = c.ContactID
190
+ WHERE bc.BidderId = :bidder_id
191
+ ORDER BY bc.Id
192
+ """)
193
+ result = conn.execute(contacts_query, {"bidder_id": bidder_id})
194
+ if result.returns_rows:
195
+ rows = result.fetchall()
196
+ cols = result.keys()
197
+ return [dict(zip(cols, r)) for r in rows]
198
+ return []
199
+ except Exception as e:
200
+ logger.warning(f"Error fetching contacts for bidder {bidder_id}: {e}")
201
+ return []
202
+
203
+ def get_bidder_notes_raw(self, bidder_id: int):
204
+ """Return raw bidder notes rows."""
205
+ try:
206
+ with self.db.get_bind().connect() as conn:
207
+ notes_query = text("""
208
+ SELECT Id, BidderId, Date, EmployeeID,
209
+ CAST(Notes AS nvarchar(max)) as Notes
210
+ FROM BidderNote
211
+ WHERE BidderId = :bidder_id
212
+ ORDER BY Date DESC
213
+ """)
214
+ result = conn.execute(notes_query, {"bidder_id": bidder_id})
215
+ if result.returns_rows:
216
+ rows = result.fetchall()
217
+ cols = result.keys()
218
+ return [dict(zip(cols, r)) for r in rows]
219
+ return []
220
+ except Exception as e:
221
+ logger.warning(f"Error fetching bidder notes for {bidder_id}: {e}")
222
+ return []
223
+
224
  def get_by_project_no(self, proj_no: str, page: int = 1, page_size: int = 10,
225
  order_by: str = "Id", order_dir: str = "ASC") -> PaginatedResponse[BidderOut]:
226
  """Get bidders for a specific project using Bidders table directly."""
app/services/project_service.py CHANGED
@@ -113,6 +113,132 @@ class ProjectService:
113
  logger.debug(f"Detail data: {detail_data}")
114
  raise NotFoundException("Error processing detailed project data")
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def list_projects(self, customer_type: int = 0, order_by: str = "project_no",
117
  order_direction: str = "asc", page: int = 1, page_size: int = 10) -> PaginatedResponse[ProjectOut]:
118
  """
@@ -568,134 +694,116 @@ class ProjectService:
568
 
569
  return detail_data
570
 
571
- def _get_project_customers(self, project_no: int) -> List[ProjectCustomerOut]:
572
  """
573
  Get customers associated with a project using stored procedures with fallback to direct SQL
574
  """
575
  try:
576
- customers = []
577
-
578
- # First try using the stored procedure
579
- with self.db.get_bind().connect() as conn:
580
- try:
581
- logger.info(f"Attempting to get customers for project {project_no} using stored procedure")
582
- result = conn.execute(text('''
583
- EXEC spBiddersGetListByParam
584
- @CustomerID = 0,
585
- @ProjectNo = :project_no,
586
- @CustTypeId = 0
587
- '''), {'project_no': project_no})
588
- rows = result.fetchall()
589
-
590
- if rows:
591
- logger.info(f"Stored procedure returned {len(rows)} customers for project {project_no}")
592
- for row in rows:
593
- row_data = dict(zip([desc[0] for desc in result.description], row))
594
- # Process stored procedure results...
595
- # Note: We'd need to map SP column names to our expected format
596
- # For now, fall through to direct SQL approach
597
- if not customers: # If SP data processing didn't work, fall back
598
- raise Exception("Stored procedure returned data but processing failed")
599
- else:
600
- logger.warning(f"Stored procedure returned 0 customers for project {project_no}, falling back to direct SQL")
601
- raise Exception("No data from stored procedure")
602
-
603
- except Exception as sp_error:
604
- logger.warning(f"Error using stored procedure for project {project_no}: {sp_error}")
605
- # Fall back to direct SQL query
606
-
607
- # Get bidders for this project using direct SQL (working approach)
608
- bidders_query = text("""
609
- SELECT
610
- ProjNo as proj_no,
611
- CustId as cust_id,
612
- Quote as quote,
613
- Contact as contact,
614
- Phone as phone,
615
- Notes as notes,
616
- DateLastContact as date_last_contact,
617
- DateFollowup as date_followup,
618
- [Primary] as is_primary,
619
- CustType as cust_type,
620
- EmailAddress as email_address,
621
- Id,
622
- Fax as fax,
623
- OrderNr as order_nr,
624
- CustomerPO as customer_po,
625
- ShipDate as ship_date,
626
- DeliverDate as deliver_date,
627
- ReplacementCost as replacement_cost,
628
- QuoteDate as quote_date,
629
- InvoiceDate as invoice_date,
630
- LessPayment as less_payment,
631
- Enabled as enabled,
632
- EmployeeId as employee_id
633
- FROM Bidders
634
- WHERE ProjNo = :project_no
635
- ORDER BY [Primary] DESC, Id
636
- """)
637
-
638
- result = conn.execute(bidders_query, {"project_no": project_no})
639
- bidder_rows = result.fetchall()
640
-
641
- logger.info(f"Direct SQL returned {len(bidder_rows)} customers for project {project_no}")
642
-
643
- for bidder_row in bidder_rows:
644
- bidder_data = dict(zip(result.keys(), bidder_row))
645
- bidder_id = bidder_data['Id']
646
-
647
- # Get barrier sizes for this bidder
648
- barrier_sizes = self._get_bidder_barrier_sizes(bidder_id)
649
-
650
- # Get contacts for this bidder
651
- contacts = self._get_bidder_contacts(bidder_id)
652
-
653
- # Get bidder notes for this bidder
654
- bidder_notes = self._get_bidder_notes(bidder_id)
655
-
656
- # Create customer object with proper type conversions
657
- replacement_cost = bidder_data.get('replacement_cost')
658
- if replacement_cost == '' or replacement_cost is None:
659
- replacement_cost = None
660
-
661
- cust_type = bidder_data.get('cust_type')
662
- if cust_type is not None:
663
- cust_type = str(cust_type)
664
-
665
- customer = ProjectCustomerOut(
666
- proj_no=bidder_data.get('proj_no', 0),
667
- cust_id=str(bidder_data.get('cust_id', '')),
668
- quote=bidder_data.get('quote'),
669
- contact=bidder_data.get('contact'),
670
- phone=bidder_data.get('phone'),
671
- notes=bidder_data.get('notes'),
672
- date_last_contact=bidder_data.get('date_last_contact'),
673
- date_followup=bidder_data.get('date_followup'),
674
- primary=bool(bidder_data.get('is_primary', False)),
675
- cust_type=cust_type,
676
- email_address=bidder_data.get('email_address'),
677
- id=bidder_id,
678
- fax=bidder_data.get('fax'),
679
- order_nr=bidder_data.get('order_nr'),
680
- customer_po=bidder_data.get('customer_po'),
681
- ship_date=bidder_data.get('ship_date'),
682
- deliver_date=bidder_data.get('deliver_date'),
683
- replacement_cost=replacement_cost,
684
- quote_date=bidder_data.get('quote_date'),
685
- invoice_date=bidder_data.get('invoice_date'),
686
- less_payment=bidder_data.get('less_payment'),
687
- barrier_sizes=barrier_sizes,
688
- contacts=contacts,
689
- bidder_notes=bidder_notes,
690
- bid_date=bidder_data.get('date_last_contact'), # Using last contact as bid date
691
- enabled=bool(bidder_data.get('enabled', True)),
692
- employee_id=bidder_data.get('employee_id')
693
- )
694
-
695
- customers.append(customer)
696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  return customers
698
-
699
  except Exception as e:
700
  logger.warning(f"Error retrieving customers for project {project_no}: {e}")
701
  return []
 
113
  logger.debug(f"Detail data: {detail_data}")
114
  raise NotFoundException("Error processing detailed project data")
115
 
116
+ def get_customers(self, project_no: int, page: int = 1, page_size: int = 100) -> List[ProjectCustomerOut]:
117
+ """
118
+ Public method to retrieve customers for a specific project.
119
+
120
+ This is a thin wrapper around the internal `_get_project_customers`
121
+ implementation so controllers can request only the customer data
122
+ without constructing the full detailed project payload.
123
+ """
124
+ # Use repository-level SQL with pagination and map results to schema objects
125
+ return self._get_project_customers(project_no, page=page, page_size=page_size)
126
+
127
+ def get_project_customer_detail(self, project_no: int, customer_id: str) -> ProjectCustomerOut:
128
+ """
129
+ Get detailed bidder information for a specific customer on a project.
130
+
131
+ Args:
132
+ project_no: The project number
133
+ customer_id: The customer ID (CustId)
134
+
135
+ Returns:
136
+ ProjectCustomerOut: Complete bidder details with nested data
137
+
138
+ Raises:
139
+ NotFoundException: If the bidder is not found
140
+ """
141
+ from app.db.repositories.bidder_repo import BidderRepository
142
+
143
+ bidder_repo = BidderRepository(self.db)
144
+ bidder_data = bidder_repo.fetch_project_bidder_by_customer_id_raw(project_no, customer_id)
145
+
146
+ if not bidder_data:
147
+ raise NotFoundException(f"Bidder not found for project {project_no} and customer {customer_id}")
148
+
149
+ bidder_id = bidder_data.get('Id')
150
+
151
+ # Fetch related data
152
+ barrier_rows = bidder_repo.get_bidder_barrier_sizes_raw(bidder_id)
153
+ contacts_rows = bidder_repo.get_bidder_contacts_raw(bidder_id)
154
+ notes_rows = bidder_repo.get_bidder_notes_raw(bidder_id)
155
+
156
+ # Map barrier rows to BarrierSizeOut
157
+ barrier_sizes = []
158
+ for br in barrier_rows:
159
+ barrier_sizes.append(BarrierSizeOut(
160
+ id=br.get('Id', 0),
161
+ inventory_id=str(br.get('InventoryId', '')) if br.get('InventoryId') is not None else None,
162
+ bidder_id=bidder_id,
163
+ barrier_size_id=br.get('BarrierSizeId', 0),
164
+ install_advisor_fees=br.get('InstallAdvisorFees'),
165
+ is_standard=bool(br.get('IsStandard', True)),
166
+ width=br.get('Width'),
167
+ length=br.get('Length'),
168
+ cable_units=br.get('CableUnits'),
169
+ height=br.get('Height'),
170
+ price=br.get('Price')
171
+ ))
172
+
173
+ # Map contacts
174
+ contacts = []
175
+ for cr in contacts_rows:
176
+ contacts.append(ContactOut(
177
+ id=cr.get('Id'),
178
+ contact_id=cr.get('ContactId'),
179
+ bidder_id=bidder_id,
180
+ enabled=bool(cr.get('Enabled', True)),
181
+ first_name=cr.get('FirstName'),
182
+ last_name=cr.get('LastName'),
183
+ title=cr.get('Title'),
184
+ email=cr.get('EmailAddress'),
185
+ phones=[],
186
+ phone1=cr.get('WorkPhone'),
187
+ phone2=cr.get('MobilePhone')
188
+ ))
189
+
190
+ # Map notes
191
+ bidder_notes = []
192
+ for nr in notes_rows:
193
+ bidder_notes.append(BidderNoteOut(
194
+ id=nr.get('Id'),
195
+ bidder_id=bidder_id,
196
+ date=nr.get('Date'),
197
+ employee_id=nr.get('EmployeeID'),
198
+ notes=nr.get('Notes', '')
199
+ ))
200
+
201
+ # Create customer object with proper type conversions
202
+ replacement_cost = bidder_data.get('replacement_cost')
203
+ if replacement_cost == '' or replacement_cost is None:
204
+ replacement_cost = None
205
+
206
+ cust_type = bidder_data.get('cust_type')
207
+ if cust_type is not None:
208
+ cust_type = str(cust_type)
209
+
210
+ customer = ProjectCustomerOut(
211
+ proj_no=bidder_data.get('proj_no', 0),
212
+ cust_id=str(bidder_data.get('cust_id', '')),
213
+ quote=bidder_data.get('quote'),
214
+ contact=bidder_data.get('contact'),
215
+ phone=bidder_data.get('phone'),
216
+ notes=bidder_data.get('notes'),
217
+ date_last_contact=bidder_data.get('date_last_contact'),
218
+ date_followup=bidder_data.get('date_followup'),
219
+ primary=bool(bidder_data.get('is_primary', False)),
220
+ cust_type=cust_type,
221
+ email_address=bidder_data.get('email_address'),
222
+ id=bidder_id,
223
+ fax=bidder_data.get('fax'),
224
+ order_nr=bidder_data.get('order_nr'),
225
+ customer_po=bidder_data.get('customer_po'),
226
+ ship_date=bidder_data.get('ship_date'),
227
+ deliver_date=bidder_data.get('deliver_date'),
228
+ replacement_cost=replacement_cost,
229
+ quote_date=bidder_data.get('quote_date'),
230
+ invoice_date=bidder_data.get('invoice_date'),
231
+ less_payment=bidder_data.get('less_payment'),
232
+ barrier_sizes=barrier_sizes,
233
+ contacts=contacts,
234
+ bidder_notes=bidder_notes,
235
+ bid_date=bidder_data.get('date_last_contact'), # Using last contact as bid date
236
+ enabled=bool(bidder_data.get('enabled', True)),
237
+ employee_id=str(bidder_data.get('employee_id')) if bidder_data.get('employee_id') is not None else None
238
+ )
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
  """
 
694
 
695
  return detail_data
696
 
697
+ def _get_project_customers(self, project_no: int, page: int = 1, page_size: int = 100) -> List[ProjectCustomerOut]:
698
  """
699
  Get customers associated with a project using stored procedures with fallback to direct SQL
700
  """
701
  try:
702
+ from app.db.repositories.bidder_repo import BidderRepository
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
 
704
+ customers = []
705
+ # Use BidderRepository for bidder-related queries
706
+ bidder_repo = BidderRepository(self.db)
707
+ bidder_rows = bidder_repo.fetch_project_bidders_raw(project_no, page=page, page_size=page_size)
708
+
709
+ logger.info(f"Repository returned {len(bidder_rows)} bidders for project {project_no}")
710
+
711
+ for bidder_data in bidder_rows:
712
+ bidder_id = bidder_data.get('Id')
713
+
714
+ # Use repository helpers to fetch related bidder data
715
+ barrier_rows = bidder_repo.get_bidder_barrier_sizes_raw(bidder_id)
716
+ contacts_rows = bidder_repo.get_bidder_contacts_raw(bidder_id)
717
+ notes_rows = bidder_repo.get_bidder_notes_raw(bidder_id)
718
+
719
+ # Map barrier rows to BarrierSizeOut
720
+ barrier_sizes = []
721
+ for br in barrier_rows:
722
+ barrier_sizes.append(BarrierSizeOut(
723
+ id=br.get('Id', 0),
724
+ inventory_id=str(br.get('InventoryId', '')) if br.get('InventoryId') is not None else None,
725
+ bidder_id=bidder_id,
726
+ barrier_size_id=br.get('BarrierSizeId', 0),
727
+ install_advisor_fees=br.get('InstallAdvisorFees'),
728
+ is_standard=bool(br.get('IsStandard', True)),
729
+ width=br.get('Width'),
730
+ length=br.get('Length'),
731
+ cable_units=br.get('CableUnits'),
732
+ height=br.get('Height'),
733
+ price=br.get('Price')
734
+ ))
735
+
736
+ # Map contacts
737
+ contacts = []
738
+ for cr in contacts_rows:
739
+ contacts.append(ContactOut(
740
+ id=cr.get('Id'),
741
+ contact_id=cr.get('ContactId'),
742
+ bidder_id=bidder_id,
743
+ enabled=bool(cr.get('Enabled', True)),
744
+ first_name=cr.get('FirstName'),
745
+ last_name=cr.get('LastName'),
746
+ title=cr.get('Title'),
747
+ email=cr.get('EmailAddress'),
748
+ phones=[],
749
+ phone1=cr.get('WorkPhone'),
750
+ phone2=cr.get('MobilePhone')
751
+ ))
752
+
753
+ # Map notes
754
+ bidder_notes = []
755
+ for nr in notes_rows:
756
+ bidder_notes.append(BidderNoteOut(
757
+ id=nr.get('Id'),
758
+ bidder_id=bidder_id,
759
+ date=nr.get('Date'),
760
+ employee_id=nr.get('EmployeeID'),
761
+ notes=nr.get('Notes', '')
762
+ ))
763
+
764
+ # Create customer object with proper type conversions
765
+ replacement_cost = bidder_data.get('replacement_cost')
766
+ if replacement_cost == '' or replacement_cost is None:
767
+ replacement_cost = None
768
+
769
+ cust_type = bidder_data.get('cust_type')
770
+ if cust_type is not None:
771
+ cust_type = str(cust_type)
772
+
773
+ customer = ProjectCustomerOut(
774
+ proj_no=bidder_data.get('proj_no', 0),
775
+ cust_id=str(bidder_data.get('cust_id', '')),
776
+ quote=bidder_data.get('quote'),
777
+ contact=bidder_data.get('contact'),
778
+ phone=bidder_data.get('phone'),
779
+ notes=bidder_data.get('notes'),
780
+ date_last_contact=bidder_data.get('date_last_contact'),
781
+ date_followup=bidder_data.get('date_followup'),
782
+ primary=bool(bidder_data.get('is_primary', False)),
783
+ cust_type=cust_type,
784
+ email_address=bidder_data.get('email_address'),
785
+ id=bidder_id,
786
+ fax=bidder_data.get('fax'),
787
+ order_nr=bidder_data.get('order_nr'),
788
+ customer_po=bidder_data.get('customer_po'),
789
+ ship_date=bidder_data.get('ship_date'),
790
+ deliver_date=bidder_data.get('deliver_date'),
791
+ replacement_cost=replacement_cost,
792
+ quote_date=bidder_data.get('quote_date'),
793
+ invoice_date=bidder_data.get('invoice_date'),
794
+ less_payment=bidder_data.get('less_payment'),
795
+ barrier_sizes=barrier_sizes,
796
+ contacts=contacts,
797
+ bidder_notes=bidder_notes,
798
+ bid_date=bidder_data.get('date_last_contact'), # Using last contact as bid date
799
+ enabled=bool(bidder_data.get('enabled', True)),
800
+ employee_id=str(bidder_data.get('employee_id')) if bidder_data.get('employee_id') is not None else None
801
+ )
802
+
803
+ customers.append(customer)
804
+
805
  return customers
806
+
807
  except Exception as e:
808
  logger.warning(f"Error retrieving customers for project {project_no}: {e}")
809
  return []
app/tests/unit/test_project_customers.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import Mock
3
+ from app.services.project_service import ProjectService
4
+ from app.schemas.project_detail import ProjectCustomerOut, BarrierSizeOut, ContactOut, BidderNoteOut
5
+
6
+ from unittest.mock import MagicMock
7
+ import app.controllers.projects as projects_controller
8
+
9
+
10
+ def make_sample_bidder_row():
11
+ return {
12
+ 'proj_no': 123,
13
+ 'cust_id': 'CUST123',
14
+ 'quote': None,
15
+ 'contact': 'Jane Doe',
16
+ 'phone': '555-1234',
17
+ 'notes': 'Important customer',
18
+ 'date_last_contact': None,
19
+ 'date_followup': None,
20
+ 'is_primary': 1,
21
+ 'cust_type': 2,
22
+ 'email_address': 'jane@example.com',
23
+ 'Id': 10,
24
+ 'fax': None,
25
+ 'order_nr': None,
26
+ 'customer_po': None,
27
+ 'ship_date': None,
28
+ 'deliver_date': None,
29
+ 'replacement_cost': None,
30
+ 'quote_date': None,
31
+ 'invoice_date': None,
32
+ 'less_payment': None,
33
+ 'enabled': 1,
34
+ 'employee_id': 5
35
+ }
36
+
37
+
38
+ def test_get_customers_service_calls_repo_methods(monkeypatch):
39
+ mock_db = Mock()
40
+ service = ProjectService(mock_db)
41
+
42
+ # Mock BidderRepository since service now creates its own instance
43
+ from app.db.repositories.bidder_repo import BidderRepository
44
+ mock_bidder_repo = Mock(spec=BidderRepository)
45
+ mock_bidder_repo.fetch_project_bidders_raw = Mock(return_value=[make_sample_bidder_row()])
46
+ mock_bidder_repo.get_bidder_barrier_sizes_raw = Mock(return_value=[{'Id': 1, 'InventoryId': 'INV1', 'BarrierSizeId': 42, 'InstallAdvisorFees': None, 'IsStandard': 1, 'Width': 10, 'Length': 20, 'CableUnits': 2, 'Height': 5, 'Price': None}])
47
+ mock_bidder_repo.get_bidder_contacts_raw = Mock(return_value=[{'Id': 2, 'ContactId': 200, 'BidderId': 10, 'Enabled': 1, 'FirstName': 'John', 'LastName': 'Smith', 'Title': 'Mgr', 'EmailAddress': 'john@example.com', 'WorkPhone': '555', 'MobilePhone': '999'}])
48
+ mock_bidder_repo.get_bidder_notes_raw = Mock(return_value=[{'Id': 3, 'BidderId': 10, 'Date': '2020-01-01T00:00:00', 'EmployeeID': '5', 'Notes': 'Note text'}])
49
+
50
+ # Patch BidderRepository instantiation
51
+ def fake_bidder_repo_init(db):
52
+ return mock_bidder_repo
53
+
54
+ monkeypatch.setattr('app.db.repositories.bidder_repo.BidderRepository', fake_bidder_repo_init)
55
+
56
+ customers = service.get_customers(123)
57
+
58
+ assert isinstance(customers, list)
59
+ assert len(customers) == 1
60
+ cust = customers[0]
61
+ assert isinstance(cust, ProjectCustomerOut)
62
+ assert cust.cust_id == 'CUST123'
63
+ assert cust.contact == 'Jane Doe'
64
+ assert cust.id == 10
65
+ assert len(cust.barrier_sizes) == 1
66
+ assert len(cust.contacts) == 1
67
+ assert len(cust.bidder_notes) == 1
68
+
69
+
70
+ def test_get_project_customers_endpoint_monkeypatched(monkeypatch):
71
+ # Monkeypatch the ProjectService.get_customers to return a known value
72
+ sample = ProjectCustomerOut(
73
+ proj_no=123,
74
+ cust_id='CUST999',
75
+ quote=None,
76
+ contact='Endpoint User',
77
+ phone='123',
78
+ notes=None,
79
+ date_last_contact=None,
80
+ date_followup=None,
81
+ primary=True,
82
+ cust_type='1',
83
+ email_address='end@example.com',
84
+ id=99,
85
+ fax=None,
86
+ order_nr=None,
87
+ customer_po=None,
88
+ ship_date=None,
89
+ deliver_date=None,
90
+ replacement_cost=None,
91
+ quote_date=None,
92
+ invoice_date=None,
93
+ less_payment=None,
94
+ barrier_sizes=[],
95
+ contacts=[],
96
+ bidder_notes=[],
97
+ bid_date=None,
98
+ enabled=True,
99
+ employee_id=None
100
+ )
101
+
102
+ def fake_get_customers(self, project_no, page=1, page_size=100):
103
+ return [sample]
104
+
105
+ monkeypatch.setattr('app.services.project_service.ProjectService.get_customers', fake_get_customers)
106
+
107
+ # Call controller function directly, injecting a mock DB session
108
+ fake_db = MagicMock()
109
+ result = projects_controller.get_project_customers(123, page=1, page_size=10, db=fake_db)
110
+ assert isinstance(result, list)
111
+ assert result[0].cust_id == 'CUST999'
112
+ assert result[0].id == 99
113
+
114
+
115
+ def test_get_project_customer_detail_service(monkeypatch):
116
+ """Test ProjectService.get_project_customer_detail method"""
117
+ mock_db = Mock()
118
+ service = ProjectService(mock_db)
119
+
120
+ sample_bidder = make_sample_bidder_row()
121
+ sample_bidder['cust_id'] = 'CUST456'
122
+
123
+ # Mock BidderRepository methods
124
+ from app.db.repositories.bidder_repo import BidderRepository
125
+ mock_bidder_repo = Mock(spec=BidderRepository)
126
+ mock_bidder_repo.fetch_project_bidder_by_customer_id_raw = Mock(return_value=sample_bidder)
127
+ mock_bidder_repo.get_bidder_barrier_sizes_raw = Mock(return_value=[])
128
+ mock_bidder_repo.get_bidder_contacts_raw = Mock(return_value=[])
129
+ mock_bidder_repo.get_bidder_notes_raw = Mock(return_value=[])
130
+
131
+ # Patch BidderRepository instantiation
132
+ def fake_bidder_repo_init(db):
133
+ return mock_bidder_repo
134
+
135
+ monkeypatch.setattr('app.db.repositories.bidder_repo.BidderRepository', fake_bidder_repo_init)
136
+
137
+ customer = service.get_project_customer_detail(123, 'CUST456')
138
+
139
+ assert isinstance(customer, ProjectCustomerOut)
140
+ assert customer.cust_id == 'CUST456'
141
+ assert customer.id == 10
142
+ assert customer.contact == 'Jane Doe'
143
+ mock_bidder_repo.fetch_project_bidder_by_customer_id_raw.assert_called_once_with(123, 'CUST456')
144
+
145
+
146
+ def test_get_project_customer_detail_endpoint(monkeypatch):
147
+ """Test controller endpoint for specific customer detail"""
148
+ sample = ProjectCustomerOut(
149
+ proj_no=123,
150
+ cust_id='CUST777',
151
+ quote=None,
152
+ contact='Detail User',
153
+ phone='456',
154
+ notes='Specific customer',
155
+ date_last_contact=None,
156
+ date_followup=None,
157
+ primary=False,
158
+ cust_type='2',
159
+ email_address='detail@example.com',
160
+ id=77,
161
+ fax=None,
162
+ order_nr='ORD123',
163
+ customer_po='PO456',
164
+ ship_date=None,
165
+ deliver_date=None,
166
+ replacement_cost=None,
167
+ quote_date=None,
168
+ invoice_date=None,
169
+ less_payment=None,
170
+ barrier_sizes=[],
171
+ contacts=[],
172
+ bidder_notes=[],
173
+ bid_date=None,
174
+ enabled=True,
175
+ employee_id='EMP5'
176
+ )
177
+
178
+ def fake_get_customer_detail(self, project_no, customer_id):
179
+ return sample
180
+
181
+ monkeypatch.setattr('app.services.project_service.ProjectService.get_project_customer_detail', fake_get_customer_detail)
182
+
183
+ fake_db = MagicMock()
184
+ result = projects_controller.get_project_customer_detail(123, 'CUST777', db=fake_db)
185
+
186
+ assert isinstance(result, ProjectCustomerOut)
187
+ assert result.cust_id == 'CUST777'
188
+ assert result.id == 77
189
+ assert result.order_nr == 'ORD123'
190
+ assert result.employee_id == 'EMP5'