diff --git a/context7.json b/context7.json index 56e1d8b3..cda228ff 100644 --- a/context7.json +++ b/context7.json @@ -30,7 +30,7 @@ "rules": [ "The package has zero runtime dependencies and ships as ESM plus a UMD build; nothing else needs to be installed to use it.", "Import from the root: import { isValidCpf } from '@brazilian-utils/brazilian-utils'. Every util is also a kebab-case subpath, e.g. '@brazilian-utils/brazilian-utils/is-valid-cpf'.", - "Use the subpaths to lazy-load the dataset-backed utils (getMunicipalities, getMunicipalityByCode, getCnae, getCbo, getCest, getCfop, getCid10, isValidCid10, getClassTrib, isValidNcm, getBanks, getNbs, getServiceItem): each one embeds a large official table.", + "Use the subpaths to lazy-load the dataset-backed utils (getMunicipalities, getMunicipalityByCode, getMunicipalitiesByAreaCode, getAreaCodeByMunicipalityCode, getCnae, getCbo, getCest, getCfop, getCid10, isValidCid10, getClassTrib, isValidNcm, getBanks, getNbs, getServiceItem): each one embeds a large official table.", "Never import the same util from both the root and its subpath in one app: a bundler treats them as two unrelated modules and bundles the dataset twice.", "Public functions never throw on bad input (null, undefined, wrong type): isValid* return false, format* and parse* return '', single-item getters return null, list getters return [].", "The only utils that reject are the async getAddressInfoByCep (GetAddressInfoByCepError: NotFound, Validation, Service) and getCepInfoByAddress (GetCepInfoByAddressError: NotFound, Validation).", diff --git a/docs/getting-started.md b/docs/getting-started.md index 77e20370..5a69c6ab 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -69,6 +69,7 @@ A few utils embed an official dataset and weigh far more than everything else co | Util | Dataset | Minified | Gzipped | | --- | --- | --- | --- | | `getCid10` | CID-10 V2008 categories and subcategories plus the SIM `U07` codes, with the DATASUS descriptions | 988.3 KB | 123.6 KB | +| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 IBGE municipalities, with the DDD of each (Anatel) | 165.0 - 167.8 KB | 52.1 - 52.8 KB | | `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 153.6 - 154.0 KB | 49.4 - 49.7 KB | | `getCities` | 5571 IBGE municipality names | 153.4 KB | 49.2 KB | | `getCbo` | CBO 2002 occupation titles | 115.7 KB | 29.5 KB | diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 74adb655..bcc5ae1f 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -69,6 +69,7 @@ Alguns utilitários embutem uma base de dados oficial e pesam muito mais que tod | Utilitário | Base de dados | Minificado | Gzip | | --- | --- | --- | --- | | `getCid10` | categorias e subcategorias da CID-10 V2008 mais os códigos `U07` do SIM, com as descrições do DATASUS | 988,3 KB | 123,6 KB | +| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 municípios do IBGE, com o DDD de cada um (Anatel) | 165,0 - 167,8 KB | 52,1 - 52,8 KB | | `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 153,6 - 154,0 KB | 49,4 - 49,7 KB | | `getCities` | nomes dos 5571 municípios do IBGE | 153,4 KB | 49,2 KB | | `getCbo` | títulos das ocupações da CBO 2002 | 115,7 KB | 29,5 KB | diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 94bd3c80..9cffaaf6 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -972,6 +972,25 @@ isValidServicePhone('11987654321'); // false (número geográfico) Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [página de SUP da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais/servicos-de-utilidade-publica-e-de-emergencia), [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151), [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86). +### getAreaCodeByMunicipalityCode + +Retorna o DDD (código de área) de um município brasileiro a partir do código IBGE de 7 dígitos, segundo a tabela da Anatel dos Códigos Nacionais em vigor. + +- Aceita o código como o `getMunicipalityByCode`: string (com todo caractere que não é dígito removido) ou número inteiro não negativo. +- Retorna o DDD como número, ou `null` quando o código não é de um município. Cada um dos 5.571 municípios tem exatamente um DDD. +- O DDD quase sempre segue a divisa dos estados. As exceções: o 61 também cobre 12 municípios de Goiás no entorno de Brasília, e Porto União (SC) usa o 42, Rio Negro (PR) o 47 e Barracão (PR) o 49. + +```javascript +import { getAreaCodeByMunicipalityCode } from '@brazilian-utils/brazilian-utils'; + +getAreaCodeByMunicipalityCode('3550308'); // 11 (São Paulo/SP) +getAreaCodeByMunicipalityCode(3304557); // 21 (Rio de Janeiro/RJ) +getAreaCodeByMunicipalityCode('4122305'); // 47 (Rio Negro/PR) +getAreaCodeByMunicipalityCode('0000000'); // null +``` + +Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Códigos Nacionais da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [tabela da Anatel dos Códigos Nacionais por município (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). + ### getAreaCodeInfo Retorna o estado e a região a que um DDD brasileiro (código de área) pertence, dentre os 67 DDDs em uso no Plano Geral de Numeração da Anatel. Aceita string ou número inteiro não negativo. @@ -1020,6 +1039,25 @@ getAreaCodesByState('XX'); // [] Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Códigos Nacionais da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [tabela da Anatel dos Códigos Nacionais por município (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). +### getMunicipalitiesByAreaCode + +Lista os municípios brasileiros que usam um DDD (código de área), segundo a tabela da Anatel dos Códigos Nacionais em vigor. + +- Aceita o DDD como o `getAreaCodeInfo`: string (com todo caractere que não é dígito removido) ou número inteiro não negativo. +- Retorna um array de `{ code, name, stateCode }` (`Municipality`): primeiro os municípios do estado sede, depois os do outro estado em que o DDD entra, cada estado em ordem alfabética. Retorna `[]` quando o DDD não está em uso. + +```javascript +import { getMunicipalitiesByAreaCode } from '@brazilian-utils/brazilian-utils'; + +getMunicipalitiesByAreaCode(68).length; // 22 (todos os municípios do Acre) +getMunicipalitiesByAreaCode('(61)').length; // 13 (Brasília e 12 municípios de Goiás) +getMunicipalitiesByAreaCode('47').at(-1); // { code: '4122305', name: 'Rio Negro', stateCode: 'PR' } +getMunicipalitiesByAreaCode('20'); // [] +``` + +Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Códigos Nacionais da Anatel](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [tabela da Anatel dos Códigos Nacionais por município (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). + + ## Placa de veículo ### isValidLicensePlate diff --git a/docs/utilities.md b/docs/utilities.md index 98f2aacd..34989aa1 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -972,6 +972,25 @@ isValidServicePhone('11987654321'); // false (geographic number) Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel SUP page](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais/servicos-de-utilidade-publica-e-de-emergencia), [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151), [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86). +### getAreaCodeByMunicipalityCode + +Get the DDD (area code) a Brazilian municipality dials, given its 7-digit IBGE code, from the Anatel table of the Códigos Nacionais in force. + +- Accepts the code the way `getMunicipalityByCode` does: a string (any non-digit characters stripped) or a non-negative integer. +- Returns the DDD as a number, or `null` when the code is not a municipality. Every one of the 5,571 municipalities has exactly one DDD. +- A DDD mostly follows state lines. The exceptions: 61 also covers 12 municipalities of Goiás around Brasília, and Porto União (SC) dials 42, Rio Negro (PR) 47 and Barracão (PR) 49. + +```javascript +import { getAreaCodeByMunicipalityCode } from '@brazilian-utils/brazilian-utils'; + +getAreaCodeByMunicipalityCode('3550308'); // 11 (São Paulo/SP) +getAreaCodeByMunicipalityCode(3304557); // 21 (Rio de Janeiro/RJ) +getAreaCodeByMunicipalityCode('4122305'); // 47 (Rio Negro/PR) +getAreaCodeByMunicipalityCode('0000000'); // null +``` + +Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel Códigos Nacionais](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [Anatel table of the Códigos Nacionais by municipality (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). + ### getAreaCodeInfo Get the state and region a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. Accepts a string or a non-negative integer. @@ -1020,6 +1039,25 @@ getAreaCodesByState('XX'); // [] Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel Códigos Nacionais](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [Anatel table of the Códigos Nacionais by municipality (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). +### getMunicipalitiesByAreaCode + +List the Brazilian municipalities that dial a given DDD (area code), from the Anatel table of the Códigos Nacionais in force. + +- Accepts the DDD the way `getAreaCodeInfo` does: a string (any non-digit characters stripped) or a non-negative integer. +- Returns an array of `{ code, name, stateCode }` (`Municipality`): the seat state's municipalities first, then those of the other state the DDD crosses into, each state's sorted by name. Returns `[]` when the DDD is not in use. + +```javascript +import { getMunicipalitiesByAreaCode } from '@brazilian-utils/brazilian-utils'; + +getMunicipalitiesByAreaCode(68).length; // 22 (every municipality of Acre) +getMunicipalitiesByAreaCode('(61)').length; // 13 (Brasília and 12 municipalities of Goiás) +getMunicipalitiesByAreaCode('47').at(-1); // { code: '4122305', name: 'Rio Negro', stateCode: 'PR' } +getMunicipalitiesByAreaCode('20'); // [] +``` + +Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [Anatel Códigos Nacionais](https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais), [Anatel table of the Códigos Nacionais by municipality (21/09/2026)](https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais). + + ## License plate ### isValidLicensePlate diff --git a/jsr.json b/jsr.json index 5842acbf..c9dfa329 100644 --- a/jsr.json +++ b/jsr.json @@ -53,6 +53,7 @@ "./generate-suframa": "./src/generate-suframa/generate-suframa.ts", "./generate-voter-id": "./src/generate-voter-id/generate-voter-id.ts", "./get-address-info-by-cep": "./src/get-address-info-by-cep/get-address-info-by-cep.ts", + "./get-area-code-by-municipality-code": "./src/get-area-code-by-municipality-code/get-area-code-by-municipality-code.ts", "./get-area-code-info": "./src/get-area-code-info/get-area-code-info.ts", "./get-area-codes-by-state": "./src/get-area-codes-by-state/get-area-codes-by-state.ts", "./get-bank-by-code": "./src/get-bank-by-code/get-bank-by-code.ts", @@ -79,6 +80,7 @@ "./get-legal-natures": "./src/get-legal-natures/get-legal-natures.ts", "./get-legal-natures-by-category": "./src/get-legal-natures-by-category/get-legal-natures-by-category.ts", "./get-municipalities": "./src/get-municipalities/get-municipalities.ts", + "./get-municipalities-by-area-code": "./src/get-municipalities-by-area-code/get-municipalities-by-area-code.ts", "./get-municipality": "./src/get-municipality/get-municipality.ts", "./get-municipality-by-code": "./src/get-municipality-by-code/get-municipality-by-code.ts", "./get-nbs": "./src/get-nbs/get-nbs.ts", diff --git a/scripts/area-codes.ts b/scripts/area-codes.ts new file mode 100644 index 00000000..fbddf1a7 --- /dev/null +++ b/scripts/area-codes.ts @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +import { DATA as STATES } from "../src/_internals/constants/states.ts"; +import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; +import { runAsEntryPoint, writeGeneratedFiles } from "./lookup-table.ts"; +import { readZipFile } from "./read-xlsx-sheet.ts"; + +const PGCN_URL = "https://www.anatel.gov.br/dadosabertos/paineis_de_dados/areastarifarias/pgcn.zip"; + +const PGCN_CSV = "Codigos_Nacionais.csv"; + +const PGCN_HEADER = [ + "CO_MUNICIPIO", + "SG_UF", + "NO_UF", + "NO_MUNICIPIO", + "CN", + "DT_INICIO_VIGENCIA", + "DT_FIM_VIGENCIA", + "DE_ALTERACAO_REGULAMENTAR", + "VIGENTE", +]; + +const MUNICIPALITY_CODE_REGEX = /^\d{7}$/; + +const AREA_CODE_REGEX = /^[1-9][1-9]$/; + +const STATE_CODES = new Set(STATES.map((state) => state.code)); + +/** + * Splits one `;` separated row of the Anatel CSV, whose fields may be quoted (`"Alta Floresta + * D'Oeste"`), a quote inside a quoted field being written twice. + * @param {string} row - The row. + * @returns {string[]} The fields of the row. + */ +const splitRow = (row: string): string[] => { + const fields: string[] = []; + let field = ""; + let quoted = false; + + for (let index = 0; index < row.length; index++) { + const character = row[index]; + + if (quoted) { + if (character === '"' && row[index + 1] === '"') { + field += '"'; + index++; + } else if (character === '"') { + quoted = false; + } else { + field += character; + } + } else if (character === '"') { + quoted = true; + } else if (character === ";") { + fields.push(field); + field = ""; + } else { + field += character; + } + } + + fields.push(field); + + return fields; +}; + +/** + * Reads the Código Nacional (DDD) in force for every municipality of the Anatel + * `Codigos_Nacionais.csv`. A municipality's earlier codes stay in the file with `VIGENTE` set to + * `Não`, so only the rows in force are kept, and a municipality with more than one of them is an + * error. + * @param {string} csv - The decoded CSV. + * @returns {Record>} The DDD of every municipality, by state and + * by 7 digit IBGE municipality code. + */ +export const parseAreaCodes = (csv: string): Record> => { + const [header, ...rows] = csv.replace(/^/, "").split(/\r?\n/); + + if (header === undefined || splitRow(header).join(";") !== PGCN_HEADER.join(";")) { + throw new Error(`${PGCN_CSV} header is not "${PGCN_HEADER.join(";")}"`); + } + + const areaCodes: Record> = {}; + + for (const row of rows) { + if (row.trim() === "") continue; + + const fields = splitRow(row); + const code = fields[0] ?? ""; + const stateCode = fields[1] ?? ""; + const areaCode = fields[4] ?? ""; + const inForce = fields[8] ?? ""; + + if (inForce !== "Sim") continue; + + if ( + !MUNICIPALITY_CODE_REGEX.test(code) || + !STATE_CODES.has(stateCode) || + !AREA_CODE_REGEX.test(areaCode) + ) { + throw new Error(`${PGCN_CSV} has an unexpected row: ${row}`); + } + + const state = areaCodes[stateCode] ?? {}; + + areaCodes[stateCode] = state; + + if (Object.hasOwn(state, code)) { + throw new Error(`${PGCN_CSV} has two codes in force for the municipality ${code}`); + } + + state[code] = Number(areaCode); + } + + return areaCodes; +}; + +/** + * Renders the module the DDD of every municipality is shipped as: for each state, one string + * holding the two digits of each municipality's DDD, in ascending order of the municipality code. + * @param {Record>} areaCodes - What `parseAreaCodes` returns. + * @returns {Record} The content of the generated file, by its path. + */ +export const renderAreaCodes = ( + areaCodes: Record>, +): Record => { + const entries = [...STATE_CODES].sort().map((stateCode) => { + const state = areaCodes[stateCode]; + + if (state === undefined) throw new Error(`${PGCN_CSV} has no municipality of ${stateCode}`); + + const digits = Object.keys(state) + .sort() + .map((code) => String(state[code])) + .join(""); + + return `\t${stateCode}: "${digits}",`; + }); + + return { + "./src/_internals/constants/municipality-area-codes.ts": `import { type StateCode } from "./states"; + +/** + * The DDD (Código Nacional) in force for every Brazilian municipality. For each state, the two + * digits of each municipality's DDD follow one another in ascending order of the 7 digit IBGE + * municipality code, so the municipality with the n-th smallest code of the state has its DDD at + * characters \`2n\` and \`2n + 1\`. The municipalities are the same 5,571 as \`DATA\` in + * \`municipalities.ts\`, which a test checks. + * + * Generated by \`node ./scripts/area-codes.ts\` from the \`Codigos_Nacionais.csv\` Anatel + * publishes, keeping only the rows in force (\`VIGENTE\` is \`Sim\`). Do not edit by hand. + * + * @see Official: https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais + * Anatel, Painel de Dados de Áreas Tarifárias, "Códigos Nacionais". + * @see Official: ${PGCN_URL} + * \`Codigos_Nacionais.csv\`, the Código Nacional of every municipality. + */ +export const MUNICIPALITY_AREA_CODES: Record = { +${entries.join("\n")} +}; +`, + }; +}; + +const main = async (): Promise => { + const response = await fetchWithRetry(PGCN_URL); + + if (!response.ok) throw new Error(`Anatel PGCN request failed with status ${response.status}`); + + const archive = Buffer.from(await response.arrayBuffer()); + + const areaCodes = parseAreaCodes(readZipFile(archive, PGCN_CSV)); + + await writeGeneratedFiles(renderAreaCodes(areaCodes)); +}; + +await runAsEntryPoint(import.meta.filename, main); diff --git a/scripts/data-summary.ts b/scripts/data-summary.ts index 8e8ce204..160c9334 100644 --- a/scripts/data-summary.ts +++ b/scripts/data-summary.ts @@ -33,6 +33,8 @@ const DATASETS: Record = { "src/_internals/constants/ibs-cbs.ts": "CST-IBS/CBS and cClassTrib (Portal Nacional da NF-e, Informe Técnico 2025.002)", "src/_internals/constants/municipalities.ts": "Municipalities (IBGE)", + "src/_internals/constants/municipality-area-codes.ts": + "DDD of every municipality (Anatel, Códigos Nacionais)", "src/_internals/constants/nbs-descriptions.ts": "NBS 2.0 descriptions (MDIC)", "src/_internals/constants/nbs.ts": "NBS 2.0 codes (MDIC)", "src/_internals/constants/service-item-descriptions.ts": diff --git a/scripts/data.ts b/scripts/data.ts index 66e3005d..f2427ffd 100644 --- a/scripts/data.ts +++ b/scripts/data.ts @@ -20,6 +20,7 @@ const run = (command: string, args: string[]): Promise => }); const generators = [ + "area-codes.ts", "banks.ts", "cbo.ts", "cest.ts", @@ -36,6 +37,7 @@ const generators = [ ]; const generatedFiles = [ + "./src/_internals/constants/municipality-area-codes.ts", "./src/_internals/constants/banks.ts", "./src/_internals/constants/cbo-descriptions.ts", "./src/_internals/constants/cbo.ts", diff --git a/scripts/read-xlsx-sheet.ts b/scripts/read-xlsx-sheet.ts index c328f0c3..7cc84cde 100644 --- a/scripts/read-xlsx-sheet.ts +++ b/scripts/read-xlsx-sheet.ts @@ -323,3 +323,13 @@ export const readXlsxSheets = (workbook: Buffer): Map => { return new Map(opened.sheets.map((sheet) => [sheet.name, readSheet(opened, sheet)])); }; + +/** + * Reads one file of a zip archive as UTF-8, for a dataset published as a zipped CSV rather than + * as a workbook. + * @param {Buffer} archive - The `.zip` file. + * @param {string} path - The path of the file inside the archive. + * @returns {string} The content of the file. + */ +export const readZipFile = (archive: Buffer, path: string): string => + readFile(unzip(archive), path); diff --git a/src/_internals/constants/municipality-area-codes.test.ts b/src/_internals/constants/municipality-area-codes.test.ts new file mode 100644 index 00000000..c0271c5d --- /dev/null +++ b/src/_internals/constants/municipality-area-codes.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "../test/runtime"; +import { AREA_CODE_SECONDARY_STATES, AREA_CODE_STATES, VALID_AREA_CODES } from "./area-codes"; +import { DATA as CITIES_DATA } from "./municipalities"; +import { MUNICIPALITY_AREA_CODES } from "./municipality-area-codes"; +import { type StateCode } from "./states"; + +const STATE_CODES = Object.keys(CITIES_DATA) as StateCode[]; + +const areaCodesOf = (stateCode: StateCode): number[] => + (MUNICIPALITY_AREA_CODES[stateCode].match(/\d{2}/g) ?? []).map(Number); + +describe("MUNICIPALITY_AREA_CODES", () => { + test("should cover the same states as the municipalities table", () => { + expect(Object.keys(MUNICIPALITY_AREA_CODES).sort()).toEqual([...STATE_CODES].sort()); + }); + + test("should hold two digits for every municipality of each state", () => { + for (const stateCode of STATE_CODES) { + expect(MUNICIPALITY_AREA_CODES[stateCode]).toMatch(/^(?:\d{2})+$/); + expect(MUNICIPALITY_AREA_CODES[stateCode].length).toBe(CITIES_DATA[stateCode].length * 2); + } + }); + + test("should only hold DDDs in use, each listing the state of the municipality", () => { + for (const stateCode of STATE_CODES) { + for (const areaCode of areaCodesOf(stateCode)) { + expect(VALID_AREA_CODES).toContain(areaCode); + expect([ + AREA_CODE_STATES[areaCode], + ...(AREA_CODE_SECONDARY_STATES[areaCode] ?? []), + ]).toContain(stateCode); + } + } + }); + + test("should give every one of the 67 DDDs, and every state a DDD lists, a municipality", () => { + const statesByAreaCode = new Map>(); + + for (const stateCode of STATE_CODES) { + for (const areaCode of areaCodesOf(stateCode)) { + const states = statesByAreaCode.get(areaCode) ?? new Set(); + + states.add(stateCode); + statesByAreaCode.set(areaCode, states); + } + } + + expect([...statesByAreaCode.keys()].sort((a, b) => a - b)).toEqual( + [...VALID_AREA_CODES].sort((a, b) => a - b), + ); + + for (const [areaCode, states] of statesByAreaCode) { + expect([...states].sort()).toEqual( + [AREA_CODE_STATES[areaCode], ...(AREA_CODE_SECONDARY_STATES[areaCode] ?? [])].sort(), + ); + } + }); +}); diff --git a/src/_internals/constants/municipality-area-codes.ts b/src/_internals/constants/municipality-area-codes.ts new file mode 100644 index 00000000..558faa63 --- /dev/null +++ b/src/_internals/constants/municipality-area-codes.ts @@ -0,0 +1,46 @@ +import { type StateCode } from "./states"; + +/** + * The DDD (Código Nacional) in force for every Brazilian municipality. For each state, the two + * digits of each municipality's DDD follow one another in ascending order of the 7 digit IBGE + * municipality code, so the municipality with the n-th smallest code of the state has its DDD at + * characters `2n` and `2n + 1`. The municipalities are the same 5,571 as `DATA` in + * `municipalities.ts`, which a test checks. + * + * Generated by `node ./scripts/area-codes.ts` from the `Codigos_Nacionais.csv` Anatel + * publishes, keeping only the rows in force (`VIGENTE` is `Sim`). Do not edit by hand. + * + * @see Official: https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais + * Anatel, Painel de Dados de Áreas Tarifárias, "Códigos Nacionais". + * @see Official: https://www.anatel.gov.br/dadosabertos/paineis_de_dados/areastarifarias/pgcn.zip + * `Codigos_Nacionais.csv`, the Código Nacional of every municipality. + */ +export const MUNICIPALITY_AREA_CODES: Record = { + AC: "68686868686868686868686868686868686868686868", + AL: "828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282828282", + AM: "9797979797979297929797929792929797929297979797979797979292979297979797929292979792929292979297929297979797929297979797979292", + AP: "96969696969696969696969696969696", + BA: "777575757577737573737575747775747775757574757377757575737573777475747777747377747573737775777577777575777775777777737777757577747777747574737173747477747375717777757575747773757774757771777475757573777575757575777775777775777773757775747371777575777575757375777574737377737574757375777773757577737773747775737373777477757773737375757375777377757574737573737573737374737373737775737173737775737373757374737773777777747374757573757573747473747377737775737573757471757777777477777571777774777773777375737573717773747574777577747775737474757775757573757375737375757377757577757477757777757375757575777477747573777473777771747373737477737575747574757775777575777777777575757571757573737375737777757775757575777575777571747573747571757571747577747774777575747177757475777777757574737575737577757475737374747377737575757474747571737775777374", + CE: "88858888888888888888858588858888858888888888888588888585888888888885858588888888858888858888888588888888888888858888858888858888888885888588858888888888888888888888888888858588888888888888888888888888888888858588888888888888888888888888888885888888858885858588888585858885888885888588888888888888888888888588888888888888888588858888888888888888858885858888858588888888", + DF: "61", + ES: "272727282727282827282727272827282728272728282728272727282728272828282728272827282727272728272728272728282727272728272827282727272727282727272728282727272727", + GO: "626264646264616264626262626464626464626464626462626462646464626264646262626264646261646464646464626262626462626262646462626264646162626462646164626464626264646464646262626462616262626264626262626464626262626262626264646264626464626262646262626462646464626464626462646261646262646264626264646262646264626462626264626262646262626262626162646264616464646464646464626264646264616462646462646462626464626462646264626462626262646261626262646462626262646264626262626262646262646462626264616262646162", + MA: "99989898999899989998999898989898999898999898989999999898989998999898989998989899989998989899989899999899989898989899999899989998999998999999999999999899999999999898999998989898999999989999999999989999999999999999999898989898989899989899989898999898999998989898989999999998989998989899989898989999989998989898989899999899999898989898989898999898989999999999989899999998999899999999999999989899999899999999999999989998989898989899989898", + MG: "34373131333334353333333535353235323333353532323331313135333533323132313233343231343437343537353233383233383531373335313232313231323131313333383132323735323837323531333535313838353538383832333531353833383838353135343131323135373535353335353438353534373531343535333231333531343338373133313232333835333135373537343537343535353134353232313133383537333432333338323234383732333433353534333137353538313531313431333531353831353833343133323237353733383335383135313435333338383535343231323831313232333337333533383331313532323337323734333533383331323132383535323734323235353238383338333132313738353133383833333333343338343133383833323533383338343335313835323238353234323435333234383835313535383137373535333538343533313831333134353431333138383133353832333135353533333437353135373133333435343531333535313833383837383133313831333533333138383333333831313238353833343738323434313335313238353733323232343833383537333532333838333333373235313331323135373333333132383138313437333332333335383232383137383538343835343335383538373831353233353333333835353538333131333438353733333832383537323731323135313338333737383232333738373535313135373235383434323233333333313137323538343132323737343533333233313231373338373431353532383237373435353337313833383133353532343534323838343331373131333233323332383831353131313331313234383132323332323234323833313334383331333232323831333833343131313333353231323235333131313532333233353434353231333337383138323531333133383734323233333837313534353835383832333333383833323135313337353331333335313533333837353233333735313532353535353331333135323235383133323135383733343538353831323532333335323833343731333133313234323535323538353334383532383334343338343831383337383538383834383834333131323333353333323235", + MS: "67676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767", + MT: "65666666666665666666666565666565666666666566656665666666666566666666656665666565656566666566666566666566666665666565666666666565666566666565656566666566666665656666666666666666666666656666656666656566666665656666656665666566666666656665656666656665666666666566666666666565666666656666", + PA: "919491919493939391919191919391919491919391949191939494919191919194919191919194919494919391949493949194919191919191939493949391919194919191939191919393919194919394939193919491949194949194939191939391919494949391919191919194919493919191949194919491919491919491919191939191939494919391919394", + PB: "83838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383838383", + PE: "8187878181878781818187818787818181878187818787818187878181878187818787878181818787878781818181878181818781818187878187818181878781818781818781878787818787878187878781818781818181818781818787878181878187878181818781878181818187878187818787818181878787878781818187818181818181878787878781878781878181818781818787818187878781878781878187818187878181818787878787878181818181", + PI: "8986868989868689868689898989898689898986898686898986898986898986898686898686868986898989898686898986898986898686868686868989898989898989898689868989868986898689898989898989898989898989868689898989898989868689898686868689898686898686898986868689898989868686868689898986868986868689868986898989898689898686898689898689898989898686868986898986898989898989898989898986898986898989868986868986898689898989898689868689898986898989898989868689898989898989", + PR: "434141414444444443444645434441424343434344414343444444414344494346434244464245414643464345444345444343434344424145414141444342424645424345424543414546444446414443434145434646444644444243414344454546444446444445464443414442434446444344444544464242434442434444424344434542414146434543444445424242444442444442444445434446414442434444434343434344434443444344434344444341424243434544444344434342444444414646434544444344444446444642434541424345454443454441444444434544444643424444434546424342444544464242444444414444454642424444444641414346424241424344464241434242444243464443424443414546444441454344464246424243434243424341474344444343464346434344444544444645424344434545434443464444434644434246444443454144424545434443444344464345434343464344444442424444444241454345414444424444424443434245464441424644", + RJ: "2422222422222424212222222122222422222222222222222124212221212222222421222222212121242124222221212221212424242424242224242122242124242221222222222122212224222224222122222121222424222424", + RN: "8484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484848484", + RO: "69696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969696969", + RR: "959595959595959595959595959595", + RS: "5354555555555554555451515155545154515154515153515153515554535151545154555551545454545455555555545451555151555555515155555151545555515454545554515554515553545351515451555351515154515454545455545453515551555451545553515154515455545154555554545455515555545154555555515554515153555551515151545554545454535455545554545151515155545455545151545454555155555455545154545551545451515451545551515553555154545454515551515155555454555555515551545551555554535554555555515154545155555551515455515551515454515451555451555451555454545154515351515154545454545454545451555551545554545554515151555554555154515555555154555151515154545153535553545554555553545553555154545151555555555151515451555455545551555453515151555551545455555555555551545554515155515553555554535555545155545555545554555455515455545554515553545154515355545555555555515555555155545455515551515455555154555154515451555455515154515454515151555155515154555551515154515155515455545155515455515553555455555451515154515154515451555454545455555455555151", + SC: "49494747494949484849494849484847494748484949474747484747484949474749474847494749494949474847494749494947494749494948474948494949494749484949494947494849494949494747494849494948494849494948474748484847494749494949494748474848474749494949494949494749474747494747494848474949474749494948494748494849474749494748474849494749484749494947484847494948484948494949494948494949474948494848474948474749494949474949494742474849474749494849474747474847494947494749494849494848474949484947484949494749484748494849494948484949484947494948484947494948484749474848494748494849494948494949474947474949494948", + SE: "797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979797979", + SP: "181719191914191415181716181117181414191617191918151418121715111815161412161914141214171911171811171418141714171215181414151517161114161417181413181811161414151115181416141411181816141518161411121219141811181113171615191112141312181614151519121117191618171717141515191817171914191814191917111616181213121911171916141716181416181319171711111819191417181814171614111817181816111118141417161714181414171715161817171418111215161311161714191914141416171416151718141611131213181918171814151917151619171815141714131511151511191516151418111513111419161911111617161217131917121116111416111817141511181311121518121914191914121819181416181418141717171111141818171814171816111717171614131718181717191119191917131916171718121916111618181812111717171715171716171818171719171614141717141716111418141718181717171818181217141718141317161819141714111716191813181814131815151217111812111914141711181914161818111714141617171715151912171816131414181818181415181412141918121814131615161516141815111615161618191911171812181714181716111819111514181717191217191619141617191116191817111719161718111619181912181813121111161719171718161612191712111214151119141112191613151417161619161317121915181911181716111814161619171519161614151818151214181715141514161217111418181717121417171714171719181119111114191716171517181419", + TO: "63636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363", +}; diff --git a/src/_internals/read-municipality-area-code/read-municipality-area-code.test.ts b/src/_internals/read-municipality-area-code/read-municipality-area-code.test.ts new file mode 100644 index 00000000..0956fbd8 --- /dev/null +++ b/src/_internals/read-municipality-area-code/read-municipality-area-code.test.ts @@ -0,0 +1,23 @@ +import { DATA as CITIES_DATA } from "../constants/municipalities"; +import { describe, expect, test } from "../test/runtime"; +import { readMunicipalityAreaCode } from "./read-municipality-area-code"; + +describe("readMunicipalityAreaCode", () => { + test("should read the DDD of a municipality", () => { + expect(readMunicipalityAreaCode("SP", "3550308")).toBe(11); + expect(readMunicipalityAreaCode("SP", "3509502")).toBe(19); + expect(readMunicipalityAreaCode("SC", "4213609")).toBe(42); + }); + + test("should read the first and the last municipality of a state, by code", () => { + const codes = CITIES_DATA.AM.map(([, code]) => code).sort(); + + expect(readMunicipalityAreaCode("AM", codes[0] ?? "")).toBe(97); + expect(readMunicipalityAreaCode("AM", codes.at(-1) ?? "")).toBe(92); + }); + + test("should give the same answer on a second lookup of the state", () => { + expect(readMunicipalityAreaCode("AC", "1200401")).toBe(68); + expect(readMunicipalityAreaCode("AC", "1200401")).toBe(68); + }); +}); diff --git a/src/_internals/read-municipality-area-code/read-municipality-area-code.ts b/src/_internals/read-municipality-area-code/read-municipality-area-code.ts new file mode 100644 index 00000000..51f08144 --- /dev/null +++ b/src/_internals/read-municipality-area-code/read-municipality-area-code.ts @@ -0,0 +1,42 @@ +import { DATA as CITIES_DATA } from "../constants/municipalities"; +import { MUNICIPALITY_AREA_CODES } from "../constants/municipality-area-codes"; +import { type StateCode } from "../constants/states"; + +const AREA_CODE_LENGTH = 2; + +const indexes = new Map>(); + +/** + * Reads the DDD of a municipality out of `MUNICIPALITY_AREA_CODES`, which holds the DDDs of each + * state in ascending order of the municipality code. The DDDs of each state are indexed by + * municipality code on its first lookup, and the index is kept. + * + * @param {StateCode} stateCode - The state of the municipality. + * @param {string} code - The 7 digit IBGE code of a municipality of that state. + * @returns {number} The DDD of the municipality. + * + * @example + * ```typescript + * readMunicipalityAreaCode("SP", "3550308"); // 11 + * ``` + */ +export const readMunicipalityAreaCode = (stateCode: StateCode, code: string): number => { + let index = indexes.get(stateCode); + + // Stryker disable next-line ConditionalExpression: this guard only memoizes; CITIES_DATA and MUNICIPALITY_AREA_CODES are module level constants that are never written to, so rebuilding the index on every lookup gives the same entries, and the repeated work is unobservable. + if (index === undefined) { + const areaCodes = MUNICIPALITY_AREA_CODES[stateCode]; + const codes = CITIES_DATA[stateCode].map(([, municipalityCode]) => municipalityCode).sort(); + + index = Object.fromEntries( + codes.map((municipalityCode, position) => { + const start = position * AREA_CODE_LENGTH; + + return [municipalityCode, Number(areaCodes.slice(start, start + AREA_CODE_LENGTH))]; + }), + ); + indexes.set(stateCode, index); + } + + return index[code]; +}; diff --git a/src/get-area-code-by-municipality-code/get-area-code-by-municipality-code.test.ts b/src/get-area-code-by-municipality-code/get-area-code-by-municipality-code.test.ts new file mode 100644 index 00000000..aea6426b --- /dev/null +++ b/src/get-area-code-by-municipality-code/get-area-code-by-municipality-code.test.ts @@ -0,0 +1,81 @@ +import * as fc from "fast-check"; + +import { VALID_AREA_CODES } from "../_internals/constants/area-codes"; +import { anyGarbage } from "../_internals/test/arbitraries"; +import { expectNeverThrows } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { getAreaCodeInfo } from "../get-area-code-info/get-area-code-info"; +import { getMunicipalities } from "../get-municipalities/get-municipalities"; +import { getAreaCodeByMunicipalityCode } from "./get-area-code-by-municipality-code"; + +describe("getAreaCodeByMunicipalityCode", () => { + it("should return the DDD of a municipality, per the Anatel Códigos Nacionais table", () => { + expect(getAreaCodeByMunicipalityCode("3550308")).toBe(11); + expect(getAreaCodeByMunicipalityCode("3509502")).toBe(19); + expect(getAreaCodeByMunicipalityCode("3304557")).toBe(21); + expect(getAreaCodeByMunicipalityCode("1200401")).toBe(68); + expect(getAreaCodeByMunicipalityCode("2605459")).toBe(81); + }); + + it("should take the code as a number", () => { + expect(getAreaCodeByMunicipalityCode(3_518_800)).toBe(11); + }); + + it("should ignore whitespace and hyphens, the same as getMunicipalityByCode", () => { + expect(getAreaCodeByMunicipalityCode(" 355-030-8 ")).toBe(11); + }); + + it("should give 61 to Brasília and to the Goiás municipalities around it", () => { + expect(getAreaCodeByMunicipalityCode("5300108")).toBe(61); + expect(getAreaCodeByMunicipalityCode("5212501")).toBe(61); + expect(getAreaCodeByMunicipalityCode("5222203")).toBe(61); + }); + + it("should give the three municipalities that dial a neighboring state's DDD that DDD", () => { + expect(getAreaCodeByMunicipalityCode("4213609")).toBe(42); + expect(getAreaCodeByMunicipalityCode("4122305")).toBe(47); + expect(getAreaCodeByMunicipalityCode("4102604")).toBe(49); + }); + + it("should return null for a code that is not a municipality", () => { + expect(getAreaCodeByMunicipalityCode("0000000")).toBeNull(); + expect(getAreaCodeByMunicipalityCode("355030")).toBeNull(); + expect(getAreaCodeByMunicipalityCode("")).toBeNull(); + }); + + it("should strip any non-digit character of a string code, as getMunicipalityByCode does", () => { + expect(getAreaCodeByMunicipalityCode("3550308 SP")).toBe(11); + }); + + it("should return null for a code with no digit, a negative or a fractional number", () => { + expect(getAreaCodeByMunicipalityCode("SP")).toBeNull(); + expect(getAreaCodeByMunicipalityCode(-3_550_308)).toBeNull(); + expect(getAreaCodeByMunicipalityCode(355_030.8)).toBeNull(); + }); + + describe("properties", () => { + test("should never throw, regardless of the input", () => { + expectNeverThrows(getAreaCodeByMunicipalityCode, anyGarbage); + }); + + test("should give every municipality a DDD in use that lists its state", () => { + const municipalityArbitrary = fc.constantFrom(...getMunicipalities()); + + fc.assert( + fc.property(municipalityArbitrary, (municipality) => { + const areaCode = getAreaCodeByMunicipalityCode(municipality.code); + + expect(VALID_AREA_CODES).toContain(areaCode); + expect(getAreaCodeInfo(areaCode ?? 0)?.stateCodes).toContain(municipality.stateCode); + }), + ); + }); + }); +}); + +describe("getAreaCodeByMunicipalityCode types", () => { + test("should take a string or number and return a number or null", () => { + expectTypeOf(getAreaCodeByMunicipalityCode).parameter(0).toEqualTypeOf(); + expectTypeOf(getAreaCodeByMunicipalityCode).returns.toEqualTypeOf(); + }); +}); diff --git a/src/get-area-code-by-municipality-code/get-area-code-by-municipality-code.ts b/src/get-area-code-by-municipality-code/get-area-code-by-municipality-code.ts new file mode 100644 index 00000000..27f2d1b8 --- /dev/null +++ b/src/get-area-code-by-municipality-code/get-area-code-by-municipality-code.ts @@ -0,0 +1,39 @@ +import { readMunicipalityAreaCode } from "../_internals/read-municipality-area-code/read-municipality-area-code"; +import { getMunicipalityByCode } from "../get-municipality-by-code/get-municipality-by-code"; + +/** + * Looks up the DDD (area code, the Código Nacional of the Plano Geral de Numeração) in force for + * a Brazilian municipality, given its 7 digit IBGE code. + * + * The code is read the way `getMunicipalityByCode` reads it: a string, with any non-digit + * characters stripped, or a non-negative integer number. Every one of the 5,571 municipalities + * has exactly one DDD, so `null` only means the code is not a municipality. + * + * A DDD mostly follows state lines, with four exceptions: 61, the DDD of Brasília, also covers + * 12 municipalities of Goiás around it, and three municipalities dial the DDD of the + * neighboring state, Rio Negro (PR) 47, Barracão (PR) 49 and Porto União (SC) 42. + * `getAreaCodeInfo` gives the state and region the DDD is seated in. + * + * @param {string|number} code - The 7 digit IBGE municipality code. + * @returns {number|null} The DDD, or `null` when `code` is not the code of a municipality. + * + * @example + * ```typescript + * getAreaCodeByMunicipalityCode("3550308"); // 11 (São Paulo/SP) + * getAreaCodeByMunicipalityCode(3304557); // 21 (Rio de Janeiro/RJ) + * getAreaCodeByMunicipalityCode("4122305"); // 47 (Rio Negro/PR) + * getAreaCodeByMunicipalityCode("0000000"); // null + * ``` + * + * @see Official: https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais + * Anatel, Painel de Dados de Áreas Tarifárias, "Códigos Nacionais". + * @see Official: https://www.anatel.gov.br/dadosabertos/paineis_de_dados/areastarifarias/pgcn.zip + * `Codigos_Nacionais.csv` of 21/09/2026, the Código Nacional of every municipality in force. + */ +export const getAreaCodeByMunicipalityCode = (code: string | number): number | null => { + const municipality = getMunicipalityByCode(code); + + if (municipality === null) return null; + + return readMunicipalityAreaCode(municipality.stateCode, municipality.code); +}; diff --git a/src/get-municipalities-by-area-code/get-municipalities-by-area-code.test.ts b/src/get-municipalities-by-area-code/get-municipalities-by-area-code.test.ts new file mode 100644 index 00000000..d2b6e630 --- /dev/null +++ b/src/get-municipalities-by-area-code/get-municipalities-by-area-code.test.ts @@ -0,0 +1,116 @@ +import * as fc from "fast-check"; + +import { VALID_AREA_CODES } from "../_internals/constants/area-codes"; +import { type Municipality } from "../_internals/constants/municipalities"; +import { anyGarbage } from "../_internals/test/arbitraries"; +import { expectNeverThrows } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { getAreaCodeByMunicipalityCode } from "../get-area-code-by-municipality-code/get-area-code-by-municipality-code"; +import { getMunicipalities } from "../get-municipalities/get-municipalities"; +import { getMunicipalitiesByAreaCode } from "./get-municipalities-by-area-code"; + +describe("getMunicipalitiesByAreaCode", () => { + it("should list every municipality of Acre for 68", () => { + expect(getMunicipalitiesByAreaCode(68)).toEqual(getMunicipalities("AC")); + }); + + it("should list the 64 municipalities of DDD 11, per the Anatel Códigos Nacionais table", () => { + const municipalities = getMunicipalitiesByAreaCode("11"); + + expect(municipalities).toHaveLength(64); + expect(municipalities).toContainEqual({ code: "3550308", name: "São Paulo", stateCode: "SP" }); + expect(municipalities).toContainEqual({ code: "3518800", name: "Guarulhos", stateCode: "SP" }); + expect(municipalities).not.toContainEqual({ + code: "3509502", + name: "Campinas", + stateCode: "SP", + }); + }); + + it("should sort each state's municipalities by name, the way getMunicipalities does", () => { + const names = getMunicipalitiesByAreaCode(11).map(({ name }) => name); + + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, "pt-BR"))); + }); + + it("should list Brasília first for 61, then the 12 Goiás municipalities around it", () => { + const municipalities = getMunicipalitiesByAreaCode("(61)"); + + expect(municipalities).toHaveLength(13); + expect(municipalities[0]).toEqual({ code: "5300108", name: "Brasília", stateCode: "DF" }); + expect(municipalities.slice(1).every(({ stateCode }) => stateCode === "GO")).toBe(true); + }); + + it("should list the one municipality across a state line after the seat state's", () => { + expect(getMunicipalitiesByAreaCode(42).at(-1)).toEqual({ + code: "4213609", + name: "Porto União", + stateCode: "SC", + }); + expect(getMunicipalitiesByAreaCode(47).at(-1)).toEqual({ + code: "4122305", + name: "Rio Negro", + stateCode: "PR", + }); + expect(getMunicipalitiesByAreaCode(49).at(-1)).toEqual({ + code: "4102604", + name: "Barracão", + stateCode: "PR", + }); + }); + + it("should return fresh objects, so mutating one leaves a later call intact", () => { + const [first] = getMunicipalitiesByAreaCode(68); + + if (first !== undefined) first.name = "MUTATED"; + + expect(getMunicipalitiesByAreaCode(68)[0]?.name).toBe("Acrelândia"); + }); + + it("should return an empty array for a DDD that is not in use, or for input that is not a DDD", () => { + expect(getMunicipalitiesByAreaCode("20")).toEqual([]); + expect(getMunicipalitiesByAreaCode("DDD")).toEqual([]); + expect(getMunicipalitiesByAreaCode(-11)).toEqual([]); + expect(getMunicipalitiesByAreaCode("")).toEqual([]); + }); + + describe("properties", () => { + test("should never throw, regardless of the input", () => { + expectNeverThrows(getMunicipalitiesByAreaCode, anyGarbage); + }); + + test("should list a municipality under the DDD getAreaCodeByMunicipalityCode gives it", () => { + const municipalityArbitrary = fc.constantFrom(...getMunicipalities()); + + fc.assert( + fc.property(municipalityArbitrary, (municipality) => { + const areaCode = getAreaCodeByMunicipalityCode(municipality.code) ?? 0; + + expect(getMunicipalitiesByAreaCode(areaCode)).toContainEqual(municipality); + }), + ); + }); + + test("should list at least one municipality for every DDD in use", () => { + for (const areaCode of VALID_AREA_CODES) { + expect(getMunicipalitiesByAreaCode(areaCode).length).toBeGreaterThan(0); + } + }); + + test("should list every municipality under exactly one DDD", () => { + const listed = VALID_AREA_CODES.flatMap((areaCode) => + getMunicipalitiesByAreaCode(areaCode).map(({ code }) => code), + ); + + expect(listed).toHaveLength(getMunicipalities().length); + expect(new Set(listed).size).toBe(listed.length); + }); + }); +}); + +describe("getMunicipalitiesByAreaCode types", () => { + test("should take a string or number and return Municipality[]", () => { + expectTypeOf(getMunicipalitiesByAreaCode).parameter(0).toEqualTypeOf(); + expectTypeOf(getMunicipalitiesByAreaCode).returns.toEqualTypeOf(); + }); +}); diff --git a/src/get-municipalities-by-area-code/get-municipalities-by-area-code.ts b/src/get-municipalities-by-area-code/get-municipalities-by-area-code.ts new file mode 100644 index 00000000..c34a8e39 --- /dev/null +++ b/src/get-municipalities-by-area-code/get-municipalities-by-area-code.ts @@ -0,0 +1,45 @@ +import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/municipalities"; +import { readMunicipalityAreaCode } from "../_internals/read-municipality-area-code/read-municipality-area-code"; +import { getAreaCodeInfo } from "../get-area-code-info/get-area-code-info"; + +export type { Municipality } from "../_internals/constants/municipalities"; + +/** + * Lists the Brazilian municipalities that dial a given DDD (area code, the Código Nacional of the + * Plano Geral de Numeração). + * + * The DDD is read the way `getAreaCodeInfo` reads it: a string, with any non-digit characters + * stripped, or a non-negative integer number. The municipalities come + * state by state, the state the DDD is seated in first and then the others of + * `AreaCodeInfo.stateCodes`, each state's sorted by name the way `getMunicipalities` sorts them. + * Four DDDs cross a state line: 61 also covers 12 municipalities of Goiás around Brasília, 42 + * covers Porto União (SC), 47 covers Rio Negro (PR) and 49 covers Barracão (PR). + * + * @param {string|number} areaCode - The DDD. + * @returns {Municipality[]} A fresh array of fresh `{ code, name, stateCode }` objects, empty + * when `areaCode` is not one of the 67 DDDs in use. + * + * @example + * ```typescript + * getMunicipalitiesByAreaCode(68).length; // 22 (every municipality of Acre) + * getMunicipalitiesByAreaCode("47").at(-1); // { code: "4122305", name: "Rio Negro", stateCode: "PR" } + * getMunicipalitiesByAreaCode("(61)").length; // 13 (Brasília and 12 municipalities of Goiás) + * getMunicipalitiesByAreaCode("20"); // [] + * ``` + * + * @see Official: https://informacoes.anatel.gov.br/paineis/areas-tarifarias/codigos-nacionais + * Anatel, Painel de Dados de Áreas Tarifárias, "Códigos Nacionais". + * @see Official: https://www.anatel.gov.br/dadosabertos/paineis_de_dados/areastarifarias/pgcn.zip + * `Codigos_Nacionais.csv` of 21/09/2026, the Código Nacional of every municipality in force. + */ +export const getMunicipalitiesByAreaCode = (areaCode: string | number): Municipality[] => { + const info = getAreaCodeInfo(areaCode); + + if (info === null) return []; + + return info.stateCodes.flatMap((stateCode) => + CITIES_DATA[stateCode] + .filter(([, code]) => readMunicipalityAreaCode(stateCode, code) === info.areaCode) + .map(([name, code]) => ({ code, name, stateCode })), + ); +}; diff --git a/src/index.test.ts b/src/index.test.ts index 0f4625d4..9c2144a3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -185,6 +185,7 @@ const PUBLIC = [ "generateSuframa", "generateVoterId", "getAddressInfoByCep", + "getAreaCodeByMunicipalityCode", "getAreaCodeInfo", "getAreaCodesByState", "getBankByCode", @@ -211,6 +212,7 @@ const PUBLIC = [ "getLegalNatures", "getLegalNaturesByCategory", "getMunicipalities", + "getMunicipalitiesByAreaCode", "getMunicipality", "getMunicipalityByCode", "getNbs", diff --git a/src/index.ts b/src/index.ts index 59afeb2d..cab25b8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,6 +92,7 @@ export { GetAddressInfoByCepValidationError, getAddressInfoByCep, } from "./get-address-info-by-cep/get-address-info-by-cep"; +export { getAreaCodeByMunicipalityCode } from "./get-area-code-by-municipality-code/get-area-code-by-municipality-code"; export { type AreaCodeInfo, getAreaCodeInfo } from "./get-area-code-info/get-area-code-info"; export { getAreaCodesByState } from "./get-area-codes-by-state/get-area-codes-by-state"; export { getBankByCode } from "./get-bank-by-code/get-bank-by-code"; @@ -153,6 +154,7 @@ export { getLegalNaturesByCategory, } from "./get-legal-natures-by-category/get-legal-natures-by-category"; export { getMunicipalities } from "./get-municipalities/get-municipalities"; +export { getMunicipalitiesByAreaCode } from "./get-municipalities-by-area-code/get-municipalities-by-area-code"; export { type GetMunicipalityByCodeParams, type GetMunicipalityByNameParams,