Plan: Travel — Link Traveler Selection to Headcount (Issue #372)¶
Context¶
Travel entries currently store traveler_name as a free-text string and traveler_id as an optional integer (intended for SCIPER). There is no enforced link to the Headcount module, so a travel entry can reference a person who is not in the unit's headcount, and names can drift (typos, inconsistent spelling).
This plan makes the traveler picker in the travel form a controlled dropdown that only shows headcount members who have a user_institutional_id (SCIPER). The traveler_name displayed in the travel table is then resolved from the headcount record instead of being stored as a duplicate string.
Prerequisite: Issue #518 (SCIPER uniqueness validation + label) must be merged first, because it guarantees that user_institutional_id is unique per carbon_report_module_id.
Data model decisions¶
Field in travel data JSON | Stores |
|---|---|
traveler_id | SCIPER as integer (= int(user_institutional_id) from headcount) |
traveler_name | Dropped from create/update payloads; resolved at read time from headcount |
Using SCIPER (not the headcount DataEntry.id) is more stable: if headcount data is re-uploaded from CSV the DataEntry IDs change but SCIPER stays constant.
traveler_name is kept in the response DTO for backward compatibility with the table, but it is no longer written by the client — the backend resolves it from headcount on every read.
Part 1 — Backend¶
1.1 New endpoint: list headcount members (lightweight)¶
File: backend/app/api/v1/carbon_report_module.py
Add a new route before the generic /{unit_id}/{year}/{module_id} route to avoid path collision:
GET /modules/{unit_id}/{year}/headcount/members
Returns the list of headcount members for the given unit/year that have a user_institutional_id. This is used exclusively to populate the travel traveler dropdown.
class HeadcountMemberDropdownItem(BaseModel):
sciper: int # = int(user_institutional_id)
name: str
@router.get(
"/{unit_id}/{year}/headcount/members",
response_model=list[HeadcountMemberDropdownItem],
)
async def list_headcount_members_for_dropdown(
unit_id: int,
year: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[HeadcountMemberDropdownItem]:
"""Return headcount members that have a SCIPER, for use in travel traveler dropdown."""
await _check_module_permission(current_user, "headcount", "view")
carbon_report_module_id = await get_carbon_report_id(
unit_id=unit_id,
year=year,
module_type_id=ModuleTypeEnum.headcount,
db=db,
)
rows = (await db.execute(
select(DataEntry.data).where(
DataEntry.carbon_report_module_id == carbon_report_module_id,
DataEntry.data_entry_type_id == DataEntryTypeEnum.member.value,
DataEntry.data["user_institutional_id"].as_string() != None,
).order_by(DataEntry.data["name"].as_string())
)).scalars().all()
result = []
for data in rows:
uid = data.get("user_institutional_id")
name = data.get("name", "")
if uid:
result.append(HeadcountMemberDropdownItem(sciper=int(uid), name=name))
return result
Important: declare this route before /{unit_id}/{year}/{module_id} (same pattern used for building-rooms, see MEMORY.md).
1.2 Travel create — validate traveler_id against headcount¶
File: backend/app/api/v1/carbon_report_module.py — create() endpoint
After validated_data = handler.validate_create(create_payload), if the entry is a travel type and traveler_id is provided, verify the SCIPER exists in the headcount for the same unit/year:
if data_entry_type in (DataEntryTypeEnum.plane, DataEntryTypeEnum.train):
sciper = validated_data.model_dump().get("traveler_id")
if sciper is not None:
headcount_crm_id = await get_carbon_report_id(
unit_id=unit_id,
year=year,
module_type_id=ModuleTypeEnum.headcount,
db=db,
)
member = (await db.execute(
select(DataEntry).where(
DataEntry.carbon_report_module_id == headcount_crm_id,
DataEntry.data_entry_type_id == DataEntryTypeEnum.member.value,
DataEntry.data["user_institutional_id"].as_string() == str(sciper),
).limit(1)
)).scalar_one_or_none()
if member is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Traveler SCIPER not found in this unit's headcount for the given year.",
)
# Resolve and inject traveler_name from headcount
validated_data_dict = validated_data.model_dump()
validated_data_dict["traveler_name"] = member.data.get("name", "")
# rebuild validated_data from dict so DataEntryService receives it
validated_data = type(validated_data).model_validate(validated_data_dict)
This ensures:
- Only headcount members can be linked.
traveler_nameis always sourced from headcount — no client-supplied value accepted.
1.3 Travel update — same validation, refresh name¶
Apply the same check in the update() endpoint when traveler_id changes. If the client sends a new traveler_id, re-validate and re-resolve traveler_name.
1.4 Travel schema changes¶
File: backend/app/modules/professional_travel/schemas.py
In ProfessionalTravelPlaneHandlerCreate and ProfessionalTravelTrainHandlerCreate:
- Keep
traveler_id: Optional[int] = None— this is the SCIPER sent by the client. - Make
traveler_name: Optional[str] = None(wasstr, required) — it will be overwritten by the backend from headcount.
In ProfessionalTravelPlaneHandlerResponse and ProfessionalTravelTrainHandlerResponse:
- Keep
traveler_name: Optional[str] = Nonefor display in tables. - Add
traveler_sciper: Optional[int] = Noneas an alias read fromtraveler_idfor frontend clarity (optional, can keep astraveler_id).
1.5 CSV ingestion — map SCIPER to headcount name¶
File: backend/app/services/data_ingestion/csv_providers/professional_travel_csv_provider.py
The CSV already accepts a sciper column → traveler_id. Extend the ingestion to:
- After parsing a row, query headcount for the same unit/year to resolve the name.
- If SCIPER is found in headcount → populate
traveler_namefrom headcount. - If SCIPER is not found → skip the row and emit a warning (do not silently accept an unlinked traveler).
# After mapping sciper -> traveler_id:
if traveler_id:
member = await _resolve_headcount_member(session, carbon_report_module_id_headcount, traveler_id)
if member is None:
logger.warning(f"Row skipped: SCIPER {traveler_id} not in headcount for unit {unit_id}/{year}")
continue
transformed_row["traveler_name"] = member.data["name"]
Add a helper _resolve_headcount_member(session, headcount_crm_id, sciper) in the same file.
Part 2 — Frontend¶
2.1 New API call: fetch headcount members dropdown¶
File: frontend/src/api/modules.ts (or wherever API calls live)
export const getHeadcountMembers = (unitId: number, year: number) =>
api.get<HeadcountMemberDropdownItem[]>(
`/modules/${unitId}/${year}/headcount/members`,
);
export interface HeadcountMemberDropdownItem {
sciper: number;
name: string;
}
2.2 Change traveler_name field to SCIPER-linked autocomplete¶
File: frontend/src/constant/module-config/professional-travel.ts
Replace the traveler_name text field with a new field type headcount-member-select:
{
id: 'traveler_id',
labelKey: `${MODULES.ProfessionalTravel}-field-traveler`,
type: 'headcount-member-select', // new field type
required: true,
sortable: true,
ratio: '1/1',
editableInline: false,
},
The traveler_name field (currently in the table) becomes read-only and is populated from the backend response — no config change needed for table display since the response still returns traveler_name.
2.3 New component: HeadcountMemberSelect.vue¶
File: frontend/src/components/modules/HeadcountMemberSelect.vue
A Quasar QSelect with:
- Options loaded from
getHeadcountMembers(unitId, year)on mount. - Option label:
"${name} (${sciper})". - Option value: the
sciperinteger. - Emits
update:modelValuewith the selectedsciper. - Emits
name-resolvedwith the resolvedname(so the form can display it in the table preview without an extra API call). - Shows a warning banner if the headcount is empty: "No headcount members with SCIPER found. Add members to Headcount first."
use-input+filterfor search-as-you-type on large lists.
Wire traveler_id ← selected sciper. The form does not send traveler_name; the backend resolves it.
2.4 Wire new component into the generic field renderer¶
File: frontend/src/components/modules/ModuleFieldInput.vue (or equivalent)
Add a branch for type === 'headcount-member-select' that renders HeadcountMemberSelect and passes unitId + year as props.
2.5 Table column: display resolved traveler_name¶
No change needed in the table config — the API response still returns traveler_name (now backend-resolved from headcount). The column labeled "Traveler" continues to show the name.
Optionally add a traveler_id (SCIPER) column in a tooltip or secondary line for power users.
2.6 i18n additions¶
File: frontend/src/i18n/professional_travel.ts
[`${MODULES.ProfessionalTravel}-field-traveler-empty-headcount`]: {
en: 'No headcount members with SCIPER found. Add members in the Headcount module first.',
fr: 'Aucun membre du personnel avec SCIPER trouvé. Ajoutez des membres dans le module Effectifs.',
},
Part 3 — Edge cases¶
| Scenario | Handling |
|---|---|
| Headcount module has no entries | Dropdown shows empty list + warning banner |
| Headcount module not yet created for unit/year | GET /headcount/members returns 404 → frontend catches it, shows "Create headcount first" message |
Travel entry created via API/CSV without traveler_id | traveler_name is required if traveler_id absent — kept for backward compatibility but deprecated |
| Headcount member deleted after travel entry linked | traveler_name stored in data JSON preserves last resolved name; no re-resolution on read (KISS) |
| SCIPER changed in headcount (edge case) | Travel retains old resolved name in data; name stays consistent |
Critical files¶
| File | Change |
|---|---|
backend/app/api/v1/carbon_report_module.py | New GET /{unit_id}/{year}/headcount/members endpoint; validate traveler_id in create() and update() |
backend/app/modules/professional_travel/schemas.py | traveler_name optional on create; add response note |
backend/app/services/data_ingestion/csv_providers/professional_travel_csv_provider.py | Validate SCIPER against headcount, resolve name |
frontend/src/api/modules.ts | getHeadcountMembers() function |
frontend/src/components/modules/HeadcountMemberSelect.vue | New — dropdown component |
frontend/src/components/modules/ModuleFieldInput.vue | Add branch for headcount-member-select type |
frontend/src/constant/module-config/professional-travel.ts | Replace traveler_name text field with traveler_id + headcount-member-select type |
frontend/src/i18n/professional_travel.ts | Add empty-headcount warning key |
Verification¶
- Headcount populated: Travel form shows a dropdown with all SCIPER members from headcount. Selecting one saves
traveler_id(SCIPER). The table shows the resolved name. - Headcount empty: Travel form shows the warning banner instead of the dropdown.
- Invalid SCIPER via API:
POST /modules/{unit_id}/{year}/professional-travel/planewithtraveler_idnot in headcount → HTTP 422"Traveler SCIPER not found in this unit's headcount for the given year.". - CSV upload: Row with a valid SCIPER that exists in headcount → imported successfully,
traveler_nameresolved automatically. Row with unknown SCIPER → row skipped, warning logged. - Name consistency: Updating a headcount member's name does not affect existing travel entries (stored name frozen in
dataJSON). New travel entries after the headcount update pick up the new name.