Classify companies by sector above industry, with the ESCO–NACE correspondence #897

Closed
opened 2026-10-12 13:52:57 +00:00 by tiagoagueda · 1 comment
Owner

Reclassify companies on two levels, sector above industry, both drawn from NACE Rev. 2.1, and link them to the occupations through the ESCO–NACE correspondence that ESCO v1.2.1 now publishes.

Why

A company's classification is one flat vocabulary called Industry (jobs/models.py:57): the 87 NACE divisions as a seed, plus whatever the person types. The word is wrong for a third of the economy. Public administration, education, health, defence and the arts are not industries in anybody's mouth, and NACE itself never uses the word: it classifies economic activities into 22 sections and 87 divisions. The translators already noticed: French, Portuguese and Spanish render Industry as Secteur, Setor and Sector, which is the name of the level above the one the field holds.

Two levels make each word accurate where it is used:

  • Sector = NACE section (22, closed, exhaustive by construction). Public administration, Education and Human health and social work are sectors nobody argues with.
  • Industry = NACE division (87) or the person's own word, a seed and never a closed list, exactly as jobs/industries.py argues today.

What exists today

  • nace-2.1.json already ships the 22 sections with names in 24 languages (sections), and industries.section_of(code) (industries.py:152) maps a division to its section. look_of (:190) already draws badges by section (#856). The hierarchy is in the data; only the word and the UI are missing.
  • Industry.code (models.py:74) follows the name (code_for, industries.py:204): a name that matches a division keeps its code, a made-up word has none.
  • Company.industries is a many-to-many (models.py:358); roles.py answers intermediary, place of learning, awarding body and membership organisation from the divisions of a company's industries (#671, #685, #686, #693).
  • Occupations are ESCO, kept at the ISCO-08 unit group (jobs/esco.py, #266); fetch_esco harvests them from the ESCO API with full=false, so the stored entry is {"isco": "2513", "names": {...}} and nothing about sectors.

The ESCO companion correspondence

ESCO published an ESCO–NACE crosswalk in December 2025 and re-issued it against NACE Rev. 2.1 in February 2026 (ESCO-NACE rev. 2.1 crosswalk.xlsx on the ESCO other crosswalks page, with a technical report). The same correspondence is in the API from v1.2.1: every occupation resource carries _links.hasNACECode, a list of NACE Rev. 2.1 concepts at group or class level. Checked against the live API on 2026-10-12:

GET /esco/api/resource/occupation?uri=…/c40a2919-…&selectedVersion=v1.2.1
hasNACECode: 62.1 Computer programming activities
             62.2 Computer consultancy and computer facilities management activities
broaderIscoGroup: 2513

The NACE concepts are their own scheme (http://data.europa.eu/ux2/nace2.1/…) with broaderConcept up to the division and the section, so the group 62.2 resolves to division 62 and section J without any table of our own. The crosswalk maps each of the 3 039 occupations to about 1.5 NACE categories on average, most at class level; truncating to the division is one string operation.

That gives Postulo a bridge it has never had between the two classifications it already carries: an occupation implies the sectors and divisions it is usually employed in, and a company's sector implies the occupations usually found there.

What would change

Model. Industry gains section (CharField, one letter, blank), filled from section_of(code) whenever the name matches a division, and pickable from the 22 sections when the name is the person's own. A custom industry with no sector stays allowed and keeps the plain look. Company gains nothing: its sectors are the sections of its industries, the same way its roles are. A migration fills section from code for every existing row.

Reference data. fetch_esco adds nace to each occupation entry: the division codes truncated from hasNACECode (["62"] for the example above), in the same harvest, with no extra consent step. The ESCO file's loader guard accepts the new key; esco.py gains divisions_for(unit_group) (the union over the group's occupations) and industries.py gains sections_for(divisions). Both are reference data under the existing EUPL 1.2 / Eurostat terms; data/ESCO-LICENCE.md records the crosswalk's provenance.

Interface.

  • Company form (templates/jobs/company_form.html:107): a Sector chooser above the industries, which narrows the datalist of suggestions to that section's divisions and the starter names that belong to it; picking an industry with a code sets the sector back, so the two never disagree.
  • Company list (jobs/tables.py:79): a Sector column and filter, grouped; the industry badge keeps its section tone.
  • Industries page (views.py:856): grouped by sector; the industry form (forms.py:568) offers the sector for words without a code.
  • Reports (#56, applications/reports.py:532), analytics (analytics.py:582) and the By industry widget (widgets.py:278): roll up to sectors, with the industry breakdown one level down.
  • Job posting form: when the company has sectors and the title matches an occupation whose NACE divisions fall outside them, nothing is refused; the suggestions for the title are ordered with that sector's occupations first (esco.suggestions, esco.py:261). Conversely, a company whose industries are empty is offered, from the titles of the postings already recorded for it, the divisions ESCO attaches to those occupations. Offered and never required, the way roles.py offers.

Archive, API, CSV. industries stays a list of names in the archive and the API (export.py:1168, api/schemas.py:125), with sector beside each name only where the person set one by hand (a code's sector is derivable and is not stored twice). FORMAT_VERSION bumps; the importer reads both shapes.

Words. English keeps Industry for the fine level and adds Sector for the coarse one. The glossary in Weblate must settle the pairs before the strings land (#706): Secteur moves up and the fine level needs a word in each language, for instance Secteur / Activité or Branche (fr), Setor / Atividade or Ramo (pt), Sector / Actividad or Rama (es), Sektor / Branche (de). The current translations of Industry as Secteur, Setor, Sector become wrong on the day the sector level appears and must be retranslated in the same release.

Decisions

  • Fine-level word in English. Recommended: keep Industry. It is what people type and what every job site calls the field; the sector above it carries the accuracy. The alternative is Activity, NACE's own word, which reads well under a sector but collides with activity feeds.
  • Where a custom industry's sector lives. Recommended: on Industry, hand-picked, nullable. The alternative, a sector directly on Company, makes two sources of truth for the coded case.
  • How far the ESCO bridge goes in this issue. Recommended: harvest the codes and use them for ordering suggestions both ways; filtering, a jobs in this sector view and sector-aware source plugins (#241) are follow-ups.
  • Order with #56 (reports to employment offices). Recommended: this first, since a report that says section J, division 62 is what an office reads.

Checked against main at cffdb2b36. The ESCO API's hasNACECode and the Rev. 2.1 crosswalk file were checked on 2026-10-12.

> Reclassify companies on two levels, **sector** above **industry**, both drawn from NACE Rev. 2.1, and link them to the occupations through the ESCO–NACE correspondence that ESCO v1.2.1 now publishes. ## Why A company's classification is one flat vocabulary called *Industry* (`jobs/models.py:57`): the 87 NACE divisions as a seed, plus whatever the person types. The word is wrong for a third of the economy. Public administration, education, health, defence and the arts are not industries in anybody's mouth, and NACE itself never uses the word: it classifies *economic activities* into 22 **sections** and 87 **divisions**. The translators already noticed: French, Portuguese and Spanish render *Industry* as *Secteur*, *Setor* and *Sector*, which is the name of the level above the one the field holds. Two levels make each word accurate where it is used: - **Sector** = NACE section (22, closed, exhaustive by construction). *Public administration*, *Education* and *Human health and social work* are sectors nobody argues with. - **Industry** = NACE division (87) or the person's own word, a seed and never a closed list, exactly as `jobs/industries.py` argues today. ## What exists today - `nace-2.1.json` already ships the 22 sections with names in 24 languages (`sections`), and `industries.section_of(code)` (`industries.py:152`) maps a division to its section. `look_of` (`:190`) already draws badges by section (#856). The hierarchy is in the data; only the word and the UI are missing. - `Industry.code` (`models.py:74`) follows the name (`code_for`, `industries.py:204`): a name that matches a division keeps its code, a made-up word has none. - `Company.industries` is a many-to-many (`models.py:358`); `roles.py` answers *intermediary*, *place of learning*, *awarding body* and *membership organisation* from the divisions of a company's industries (#671, #685, #686, #693). - Occupations are ESCO, kept at the ISCO-08 unit group (`jobs/esco.py`, #266); `fetch_esco` harvests them from the ESCO API with `full=false`, so the stored entry is `{"isco": "2513", "names": {...}}` and nothing about sectors. ## The ESCO companion correspondence ESCO published an ESCO–NACE crosswalk in December 2025 and re-issued it against **NACE Rev. 2.1** in February 2026 (`ESCO-NACE rev. 2.1 crosswalk.xlsx` on the ESCO *other crosswalks* page, with a technical report). The same correspondence is **in the API** from v1.2.1: every occupation resource carries `_links.hasNACECode`, a list of NACE Rev. 2.1 concepts at group or class level. Checked against the live API on 2026-10-12: ``` GET /esco/api/resource/occupation?uri=…/c40a2919-…&selectedVersion=v1.2.1 hasNACECode: 62.1 Computer programming activities 62.2 Computer consultancy and computer facilities management activities broaderIscoGroup: 2513 ``` The NACE concepts are their own scheme (`http://data.europa.eu/ux2/nace2.1/…`) with `broaderConcept` up to the division and the section, so the group `62.2` resolves to division `62` and section `J` without any table of our own. The crosswalk maps each of the 3 039 occupations to about 1.5 NACE categories on average, most at class level; truncating to the division is one string operation. That gives Postulo a bridge it has never had between the two classifications it already carries: an occupation implies the sectors and divisions it is usually employed in, and a company's sector implies the occupations usually found there. ## What would change **Model.** `Industry` gains `section` (`CharField`, one letter, blank), filled from `section_of(code)` whenever the name matches a division, and pickable from the 22 sections when the name is the person's own. A custom industry with no sector stays allowed and keeps the plain look. `Company` gains nothing: its sectors are the sections of its industries, the same way its roles are. A migration fills `section` from `code` for every existing row. **Reference data.** `fetch_esco` adds `nace` to each occupation entry: the division codes truncated from `hasNACECode` (`["62"]` for the example above), in the same harvest, with no extra consent step. The ESCO file's loader guard accepts the new key; `esco.py` gains `divisions_for(unit_group)` (the union over the group's occupations) and `industries.py` gains `sections_for(divisions)`. Both are reference data under the existing EUPL 1.2 / Eurostat terms; `data/ESCO-LICENCE.md` records the crosswalk's provenance. **Interface.** - Company form (`templates/jobs/company_form.html:107`): a *Sector* chooser above the industries, which narrows the datalist of suggestions to that section's divisions and the starter names that belong to it; picking an industry with a code sets the sector back, so the two never disagree. - Company list (`jobs/tables.py:79`): a *Sector* column and filter, grouped; the industry badge keeps its section tone. - Industries page (`views.py:856`): grouped by sector; the industry form (`forms.py:568`) offers the sector for words without a code. - Reports (#56, `applications/reports.py:532`), analytics (`analytics.py:582`) and the *By industry* widget (`widgets.py:278`): roll up to sectors, with the industry breakdown one level down. - Job posting form: when the company has sectors and the title matches an occupation whose NACE divisions fall outside them, nothing is refused; the suggestions for the title are ordered with that sector's occupations first (`esco.suggestions`, `esco.py:261`). Conversely, a company whose industries are empty is offered, from the titles of the postings already recorded for it, the divisions ESCO attaches to those occupations. Offered and never required, the way `roles.py` offers. **Archive, API, CSV.** `industries` stays a list of names in the archive and the API (`export.py:1168`, `api/schemas.py:125`), with `sector` beside each name only where the person set one by hand (a code's sector is derivable and is not stored twice). `FORMAT_VERSION` bumps; the importer reads both shapes. **Words.** English keeps *Industry* for the fine level and adds *Sector* for the coarse one. The glossary in Weblate must settle the pairs before the strings land (#706): *Secteur* moves up and the fine level needs a word in each language, for instance *Secteur / Activité* or *Branche* (fr), *Setor / Atividade* or *Ramo* (pt), *Sector / Actividad* or *Rama* (es), *Sektor / Branche* (de). The current translations of *Industry* as *Secteur*, *Setor*, *Sector* become wrong on the day the sector level appears and must be retranslated in the same release. ## Decisions - **Fine-level word in English.** Recommended: keep *Industry*. It is what people type and what every job site calls the field; the sector above it carries the accuracy. The alternative is *Activity*, NACE's own word, which reads well under a sector but collides with activity feeds. - **Where a custom industry's sector lives.** Recommended: on `Industry`, hand-picked, nullable. The alternative, a sector directly on `Company`, makes two sources of truth for the coded case. - **How far the ESCO bridge goes in this issue.** Recommended: harvest the codes and use them for ordering suggestions both ways; filtering, a *jobs in this sector* view and sector-aware source plugins (#241) are follow-ups. - **Order with #56 (reports to employment offices).** Recommended: this first, since a report that says *section J, division 62* is what an office reads. Checked against `main` at `cffdb2b36`. The ESCO API's `hasNACECode` and the Rev. 2.1 crosswalk file were checked on 2026-10-12.
Author
Owner

Fixed in a17d60c23. The recommended answer was taken for each decision: Industry stays the fine-level word, a custom industry's sector lives on the industry (chosen by hand, optional), and the ESCO bridge orders suggestions both ways. Weblate: the fr/pt/es translations of “Industry” (Secteur, Setor, Sector) now name the level above and need retranslating.

Fixed in a17d60c23. The recommended answer was taken for each decision: Industry stays the fine-level word, a custom industry's sector lives on the industry (chosen by hand, optional), and the ESCO bridge orders suggestions both ways. Weblate: the fr/pt/es translations of “Industry” (Secteur, Setor, Sector) now name the level above and need retranslating.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#897
No description provided.