Spaces:
Paused
Paused
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 +36 -1
- app/db/repositories/bidder_repo.py +163 -0
- app/services/project_service.py +230 -122
- app/tests/unit/test_project_customers.py +190 -0
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 |
-
|
| 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'
|