refactor: introduit des types du domain pour découpler notre code de celui de l'API Fabrique Social

pull/2943/head
Jalil Arfaoui 2024-02-10 23:14:54 +01:00
parent a4d237a79f
commit 6648bd6f29
19 changed files with 233 additions and 130 deletions

View File

@ -1,9 +1,49 @@
import { Company } from '@/store/reducers/companySituationReducer'
import { codeActivité } from '@/domain/CodeActivite'
import { codeCatégorieJuridique } from '@/domain/CodeCatégorieJuridique'
import { Entreprise } from '@/domain/Entreprise'
import { Établissement } from '@/domain/Établissement'
import { siren, siret } from '@/domain/Siren'
export async function searchDenominationOrSiren(value: string) {
return searchFullText(value)
export async function searchDenominationOrSiren(
searchTerm: string
): Promise<Array<Entreprise> | null> {
return searchFullText(searchTerm).then(
(entreprises) => entreprises?.map(fabriqueSocialEntrepriseAdapter) || null
)
}
export const fabriqueSocialEntrepriseAdapter = (
entreprise: FabriqueSocialEntreprise
): Entreprise => {
const siège = entreprise && getSiege(entreprise)
return {
nom: entreprise.label,
siren: siren(entreprise.siren),
dateDeCréation: new Date(entreprise.dateCreationUniteLegale),
codeCatégorieJuridique: codeCatégorieJuridique(
entreprise.categorieJuridiqueUniteLegale
),
activitéPrincipale: codeActivité(entreprise.activitePrincipale),
siège: siège && établissementAdapter(siège),
établissement: établissementAdapter(entreprise.firstMatchingEtablissement),
}
}
const établissementAdapter = (
fabriqueSocialEtablissement: FabriqueSocialEtablissement
): Établissement => ({
siret: siret(fabriqueSocialEtablissement.siret),
activitéPrincipale: codeActivité(
fabriqueSocialEtablissement.activitePrincipaleEtablissement
),
adresse: {
complète: fabriqueSocialEtablissement.address,
codePostal: fabriqueSocialEtablissement.codePostalEtablissement,
codeCommune: fabriqueSocialEtablissement.codeCommuneEtablissement,
},
})
/*
* Fields are documented in https://www.sirene.fr/static-resources/doc/Description%20fichier%20StockUniteLegaleHistorique.pdf?version=1.33.1
*/
@ -67,10 +107,10 @@ async function searchFullText(
return json.entreprises
}
export function getSiegeOrFirstEtablissement(
entreprise: FabriqueSocialEntreprise | Company
): FabriqueSocialEtablissement {
return (entreprise.allMatchingEtablissements.find(
function getSiege(
entreprise: FabriqueSocialEntreprise
): FabriqueSocialEtablissement | undefined {
return entreprise.allMatchingEtablissements.find(
(etablissement) => etablissement.etablissementSiege
) || entreprise.firstMatchingEtablissement)!
)
}

View File

@ -1,23 +1,23 @@
import { Fragment, useMemo } from 'react'
import { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { styled } from 'styled-components'
import {
FabriqueSocialEntreprise,
getSiegeOrFirstEtablissement,
} from '@/api/fabrique-social'
import { Spacing } from '@/design-system/layout'
import { Strong } from '@/design-system/typography'
import { H4 } from '@/design-system/typography/heading'
import {
Entreprise,
établissementEstDifférentDuSiège,
} from '@/domain/Entreprise'
export default function CompanySearchDetails({
export default function EntrepriseSearchDetails({
entreprise,
}: {
entreprise: FabriqueSocialEntreprise
entreprise: Entreprise
}) {
const { i18n } = useTranslation()
const { siren, label, dateCreationUniteLegale } = entreprise
const { nom, siren, siège, établissement, dateDeCréation } = entreprise
const DateFormatter = useMemo(
() =>
@ -29,8 +29,6 @@ export default function CompanySearchDetails({
[i18n.language]
)
const siegeOrFirstEtablissement = getSiegeOrFirstEtablissement(entreprise)
return (
<CompanyContainer>
<H4
@ -40,49 +38,25 @@ export default function CompanySearchDetails({
}}
>
<>
{'highlightLabel' in entreprise
? highlightLabelToJSX(entreprise.highlightLabel)
: label}{' '}
<small>({siren})</small>
{nom} <small>({siren})</small>
</>
</H4>
<Spacing sm />
<Trans>Crée le :</Trans>{' '}
<Strong>{DateFormatter.format(new Date(dateCreationUniteLegale))}</Strong>
<Strong>{DateFormatter.format(dateDeCréation)}</Strong>
{établissementEstDifférentDuSiège(entreprise) && (
<>
<br />
<Trans>Siège :</Trans> <Strong>{siège?.adresse.complète}</Strong>
</>
)}
<br />
<Trans>Domiciliée à l'adresse :</Trans>{' '}
<Strong>{siegeOrFirstEtablissement.address}</Strong>
<Trans>Établissement recherché:</Trans>{' '}
<Strong>{établissement?.adresse.complète}</Strong>
</CompanyContainer>
)
}
function highlightLabelToJSX(highlightLabel: string) {
const highlightRE = /(.*?)<b><u>(.+?)<\/u><\/b>/gm
let parsedLength = 0
const result = []
let matches: RegExpExecArray | null = null
while ((matches = highlightRE.exec(highlightLabel)) !== null) {
parsedLength += matches[0].length
result.push(
<Fragment key={matches[2]}>
{matches[1]}
<Highlight>{matches[2]}</Highlight>
</Fragment>
)
}
result.push(highlightLabel.slice(parsedLength))
return result
}
const Highlight = styled.strong`
background-color: ${({ theme }) =>
theme.darkMode
? theme.colors.bases.secondary[600]
: theme.colors.bases.secondary[100]};
color: inherit;
`
const CompanyContainer = styled.div`
text-align: left;
`

View File

@ -3,7 +3,6 @@ import { ReactNode, useEffect, useRef } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { styled } from 'styled-components'
import { FabriqueSocialEntreprise } from '@/api/fabrique-social'
import { ForceThemeProvider } from '@/components/utils/DarkModeContext'
import { Message } from '@/design-system'
import { Card } from '@/design-system/card'
@ -15,10 +14,11 @@ import { Strong } from '@/design-system/typography'
import { StyledLink } from '@/design-system/typography/link'
import { Li, Ul } from '@/design-system/typography/list'
import { Body } from '@/design-system/typography/paragraphs'
import { Entreprise } from '@/domain/Entreprise'
import useSearchCompany from '@/hooks/useSearchCompany'
import { Appear, FromTop } from '../ui/animate'
import CompanySearchDetails from './SearchDetails'
import EntrepriseSearchDetails from './SearchDetails'
const StyledCard = styled(Card)`
flex-direction: row; // for Safari <= 13
@ -28,14 +28,14 @@ const StyledCard = styled(Card)`
}
`
export function CompanySearchField(props: {
export function EntrepriseSearchField(props: {
label?: ReactNode
onValue?: () => void
onClear?: () => void
onSubmit?: (search: FabriqueSocialEntreprise | null) => void
onSubmit?: (search: Entreprise | null) => void
}) {
const { t } = useTranslation()
const refResults = useRef<FabriqueSocialEntreprise[] | null>(null)
const refResults = useRef<Entreprise[] | null>(null)
const searchFieldProps = {
...props,
@ -102,8 +102,8 @@ function Results({
results,
onSubmit,
}: {
results: Array<FabriqueSocialEntreprise>
onSubmit?: (établissement: FabriqueSocialEntreprise) => void
results: Array<Entreprise>
onSubmit?: (entreprise: Entreprise) => void
}) {
const { t } = useTranslation()
@ -152,14 +152,14 @@ function Results({
<FromTop>
<ForceThemeProvider>
<Ul noMarker data-test-id="company-search-results">
{results.map((etablissement) => (
<Li key={etablissement.siren}>
{results.map((entreprise) => (
<Li key={entreprise.siren}>
<StyledCard
onPress={() => onSubmit?.(etablissement)}
onClick={() => onSubmit?.(etablissement)}
onPress={() => onSubmit?.(entreprise)}
onClick={() => onSubmit?.(entreprise)}
compact
bodyAs="div"
aria-label={`${etablissement.label}, Selectionner cette entreprise`}
aria-label={`${entreprise.nom}, Selectionner cette entreprise`}
ctaLabel={
<ChevronIcon
style={{
@ -170,7 +170,7 @@ function Results({
/>
}
>
<CompanySearchDetails entreprise={etablissement} />
<EntrepriseSearchDetails entreprise={entreprise} />
</StyledCard>
</Li>
))}

View File

@ -0,0 +1,5 @@
export interface Adresse {
complète?: string
codePostal: string
codeCommune: string
}

View File

@ -0,0 +1 @@
export type Brand<T, U extends string> = T & { __tag: U }

View File

@ -0,0 +1,6 @@
import { Brand } from '@/domain/Brand'
export type CodeActivite = Brand<string, 'CodeActivite'>
// Pourrait être inféré des données de fetchBénéfice
export const codeActivité = (code: string) => code as CodeActivite

View File

@ -0,0 +1,6 @@
import { Brand } from '@/domain/Brand'
export type CodeCatégorieJuridique = Brand<string, 'CodeCatégorieJuridique'>
export const codeCatégorieJuridique = (code: string) =>
code as CodeCatégorieJuridique

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { formatDate, parsePublicodesDateString } from '@/domain/Date'
describe('parsePublicodesDateString', () => {
it('comprend 24-12-2024 comme le 24 décembre 2024', () => {
expect(parsePublicodesDateString('24/12/2024')).toEqual(
new Date(2024, 11, 24)
)
})
})
describe('formatDate', () => {
it("écrit le 15 août 1980 comme '15/08/1980' (format Publicodes)", () => {
expect(formatDate(new Date('1980-08-15'))).toEqual('15/08/1980')
})
})

View File

@ -0,0 +1,10 @@
import { format, parse } from 'date-fns/fp'
export const publicodesStandardDateFormat = 'dd/MM/yyyy'
export const formatDate = format(publicodesStandardDateFormat)
export const parsePublicodesDateString = parse(
new Date(),
publicodesStandardDateFormat
)

View File

@ -0,0 +1,24 @@
import { CodeActivite } from '@/domain/CodeActivite'
import { CodeCatégorieJuridique } from '@/domain/CodeCatégorieJuridique'
import { Établissement } from '@/domain/Établissement'
import { Siren } from '@/domain/Siren'
export interface Entreprise {
nom: string
siren: Siren
dateDeCréation: Date
codeCatégorieJuridique: CodeCatégorieJuridique
activitéPrincipale: CodeActivite
siège?: Établissement
établissement: Établissement
}
export const établissementEstLeSiège = (entreprise: Entreprise): boolean =>
!!entreprise.siège &&
!!entreprise.siège.adresse.complète &&
entreprise.siège.adresse.complète ===
entreprise.établissement.adresse.complète
export const établissementEstDifférentDuSiège = (
entreprise: Entreprise
): boolean => !établissementEstLeSiège(entreprise)

View File

@ -0,0 +1,7 @@
import { Brand } from '@/domain/Brand'
export type Siren = Brand<string, 'Siren'>
export const siren = (value: string): Siren => value as Siren
export type Siret = Brand<string, 'Siret'>
export const siret = (value: string): Siret => value as Siret

View File

@ -0,0 +1,9 @@
import { Adresse } from '@/domain/Adresse'
import { CodeActivite } from '@/domain/CodeActivite'
import { Siret } from '@/domain/Siren'
export interface Établissement {
siret: Siret
adresse: Adresse
activitéPrincipale: CodeActivite
}

View File

@ -1,22 +1,19 @@
import { useEffect, useState } from 'react'
import {
FabriqueSocialEntreprise,
searchDenominationOrSiren,
} from '@/api/fabrique-social'
import { searchDenominationOrSiren } from '@/api/fabrique-social'
import { Entreprise } from '@/domain/Entreprise'
import { useDebounce } from './useDebounce'
export default function useSearchCompany(
value: string
): [boolean, Array<FabriqueSocialEntreprise>] {
const [result, setResult] = useState<Array<FabriqueSocialEntreprise>>([])
): [boolean, Array<Entreprise>] {
const [result, setResult] = useState<Array<Entreprise>>([])
const [searchPending, setSearchPending] = useState(Boolean(value))
const debouncedValue = useDebounce(value, 300)
useEffect(() => {
setSearchPending(Boolean(value))
if (!value) {
setResult([])
}
@ -28,7 +25,7 @@ export default function useSearchCompany(
}
searchDenominationOrSiren(debouncedValue)
.then((entreprise: Array<FabriqueSocialEntreprise> | null) => {
.then((entreprise: Array<Entreprise> | null) => {
setResult(entreprise || [])
setSearchPending(false)
})

View File

@ -2,10 +2,7 @@ import { useDispatch } from 'react-redux'
import fetchBénéfice from '@/api/activité-vers-bénéfice'
import { fetchCommuneDetails } from '@/api/commune'
import {
FabriqueSocialEntreprise,
getSiegeOrFirstEtablissement,
} from '@/api/fabrique-social'
import { Entreprise } from '@/domain/Entreprise'
import {
addCommuneDetails,
setBénéficeType,
@ -15,24 +12,20 @@ import {
export function useSetEntreprise() {
const dispatch = useDispatch()
return (entreprise: FabriqueSocialEntreprise | null) => {
if (entreprise === null) {
return (entreprise: Entreprise | null) => {
if (entreprise === null || !entreprise.établissement.adresse) {
return
}
dispatch(setCompany(entreprise))
const siegeOrFirstEtablissement = getSiegeOrFirstEtablissement(entreprise)
void fetchCommuneDetails(
siegeOrFirstEtablissement.codeCommuneEtablissement
).then(
void fetchCommuneDetails(entreprise.établissement.adresse.codeCommune).then(
(communeDetails) =>
communeDetails && dispatch(addCommuneDetails(communeDetails))
)
void fetchBénéfice(
siegeOrFirstEtablissement.activitePrincipaleEtablissement
).then((bénéfice) => bénéfice && dispatch(setBénéficeType(bénéfice)))
void fetchBénéfice(entreprise.établissement.activitéPrincipale).then(
(bénéfice) => bénéfice && dispatch(setBénéficeType(bénéfice))
)
}
}

View File

@ -3,12 +3,9 @@ import { Trans, useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { generatePath, useNavigate } from 'react-router-dom'
import {
FabriqueSocialEntreprise,
searchDenominationOrSiren,
} from '@/api/fabrique-social'
import { searchDenominationOrSiren } from '@/api/fabrique-social'
import { CompanyDetails } from '@/components/company/Details'
import { CompanySearchField } from '@/components/company/SearchField'
import { EntrepriseSearchField } from '@/components/company/SearchField'
import { useEngine } from '@/components/utils/EngineContext'
import AnswerGroup from '@/design-system/answer-group'
import { Button } from '@/design-system/buttons'
@ -16,6 +13,7 @@ import { Grid, Spacing } from '@/design-system/layout'
import PopoverConfirm from '@/design-system/popover/PopoverConfirm'
import { H3 } from '@/design-system/typography/heading'
import { Body } from '@/design-system/typography/paragraphs'
import { Entreprise } from '@/domain/Entreprise'
import { useSetEntreprise } from '@/hooks/useSetEntreprise'
import { useSitePaths } from '@/sitePaths'
import { getCookieValue } from '@/storage/readCookie'
@ -85,7 +83,7 @@ export default function SearchOrCreate() {
activité
</Body>
</Trans>
<CompanySearchField onSubmit={handleCompanySubmit} />
<EntrepriseSearchField onSubmit={handleCompanySubmit} />
<Spacing md />
</>
)}
@ -100,7 +98,7 @@ function useHandleCompanySubmit() {
const setEntreprise = useSetEntreprise()
const handleCompanySubmit = useCallback(
(établissement: FabriqueSocialEntreprise | null) => {
(établissement: Entreprise | null) => {
if (!établissement) {
return
}

View File

@ -14,10 +14,7 @@ import {
} from 'react-router-dom'
import { styled } from 'styled-components'
import {
FabriqueSocialEntreprise,
searchDenominationOrSiren,
} from '@/api/fabrique-social'
import { searchDenominationOrSiren } from '@/api/fabrique-social'
import { TrackPage } from '@/components/ATInternetTracking'
import { CompanyDetails } from '@/components/company/Details'
import RuleInput from '@/components/conversation/RuleInput'
@ -36,6 +33,7 @@ import { Container, Grid, Spacing } from '@/design-system/layout'
import { Strong } from '@/design-system/typography'
import { H2, H3 } from '@/design-system/typography/heading'
import { Body, Intro } from '@/design-system/typography/paragraphs'
import { Entreprise } from '@/domain/Entreprise'
import { useQuestionList } from '@/hooks/useQuestionList'
import { useSetEntreprise } from '@/hooks/useSetEntreprise'
import useSimulationConfig from '@/hooks/useSimulationConfig'
@ -399,9 +397,7 @@ const usePourMonEntreprisePath = () => {
const useSirenFromParams = (overwrite: boolean) => {
const { entreprise: param } = useParams<{ entreprise?: string }>()
const [entreprise, setEntreprise] = useState<FabriqueSocialEntreprise | null>(
null
)
const [entreprise, setEntreprise] = useState<Entreprise | null>(null)
const [entreprisePending, setEntreprisePending] = useState(false)
const [entrepriseNotFound, setEntrepriseNotFound] = useState(false)

View File

@ -1,6 +1,6 @@
import { Bénéfice } from '@/api/activité-vers-bénéfice'
import { Commune } from '@/api/commune'
import { FabriqueSocialEntreprise } from '@/api/fabrique-social'
import { Entreprise } from '@/domain/Entreprise'
export type CompanyActions = ReturnType<
| typeof resetCompany
@ -26,7 +26,7 @@ export const setBénéficeType = (bénéfice: NonNullable<Bénéfice>) =>
bénéfice,
}) as const
export const setCompany = (entreprise: FabriqueSocialEntreprise) => {
export const setCompany = (entreprise: Entreprise) => {
return {
type: 'COMPANY::SET_EXISTING_COMPANY',
entreprise,

View File

@ -1,9 +1,8 @@
import { DottedName } from 'modele-social'
import {
FabriqueSocialEntreprise,
getSiegeOrFirstEtablissement,
} from '@/api/fabrique-social'
import { CodeCatégorieJuridique } from '@/domain/CodeCatégorieJuridique'
import { formatDate } from '@/domain/Date'
import { Entreprise } from '@/domain/Entreprise'
import { Action } from '@/store/actions/actions'
import { buildSituationFromObject, omit } from '@/utils'
@ -38,8 +37,6 @@ export function isCompanyDottedName(dottedName: DottedName) {
return SAVED_NAMESPACES.some((namespace) => dottedName.startsWith(namespace))
}
export type Company = Omit<FabriqueSocialEntreprise, 'highlightLabel'>
export function companySituation(state: Situation = {}, action: Action) {
switch (action.type) {
case 'UPDATE_SITUATION':
@ -86,27 +83,24 @@ export function companySituation(state: Situation = {}, action: Action) {
return state
}
export function getCompanySituation(company: Company): Situation {
const siegeOrFirstEtablissement = getSiegeOrFirstEtablissement(company)
export function getCompanySituation(entreprise: Entreprise): Situation {
return {
'entreprise . date de création': company.dateCreationUniteLegale.replace(
/(.*)-(.*)-(.*)/,
'$3/$2/$1'
),
'entreprise . date de création': formatDate(entreprise.dateDeCréation),
'entreprise . catégorie juridique': `'${getCatégorieFromCode(
company.categorieJuridiqueUniteLegale
entreprise.codeCatégorieJuridique
)}'`,
'entreprise . SIREN': `'${company.siren}'`,
'entreprise . nom': `'${company.label}'`,
'établissement . SIRET': `'${siegeOrFirstEtablissement.siret}'`,
'entreprise . activité': `'${company.activitePrincipale}'`,
'entreprise . SIREN': `'${entreprise.siren}'`,
'entreprise . nom': `'${entreprise.nom}'`,
'établissement . SIRET': `'${entreprise.établissement.siret}'`,
'entreprise . activité': `'${entreprise.activitéPrincipale}'`,
}
}
type CatégorieJuridique = 'EI' | 'SARL' | 'SAS' | 'SELARL' | 'SELAS' | 'autre'
const getCatégorieFromCode = (code: string): CatégorieJuridique => {
const getCatégorieFromCode = (
code: CodeCatégorieJuridique
): CatégorieJuridique => {
/*
Nous utilisons le code entreprise pour connaitre le statut juridique
(voir https://www.insee.fr/fr/information/2028129)

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getSiegeOrFirstEtablissement } from '@/api/fabrique-social'
import { fabriqueSocialEntrepriseAdapter } from '@/api/fabrique-social'
import {
fabriqueSocialWithoutSiege,
@ -8,14 +8,40 @@ import {
} from './fabrique-social.fixtures'
describe('Fabrique Social', () => {
describe('getSiegeOrFirstEtablissement Function', () => {
it('should return siege', () => {
const siege = getSiegeOrFirstEtablissement(fabriqueSocialWithSiege)
expect(siege.address).toBe('23 RUE DE MOGADOR 75009 PARIS 9')
describe('fabriqueSocialEntrepriseAdapter', () => {
describe('Si lentreprise est retournée avec un siège différent de la recherche', () => {
const entreprise = fabriqueSocialEntrepriseAdapter(
fabriqueSocialWithSiege
)
it('retourne le siren', () => {
expect(entreprise.siren).to.equal('849074190')
})
it("a l'établissement demandé dans 'établissement'", () => {
expect(entreprise.siège?.adresse.complète).to.equal(
'23 RUE DE MOGADOR 75009 PARIS 9'
)
})
it("a le siège dans 'siège'", () => {
expect(entreprise.établissement.adresse.complète).to.equal(
'4 RUE VOLTAIRE 44000 NANTES'
)
})
})
it('should return FirstEtablissement', () => {
const siege = getSiegeOrFirstEtablissement(fabriqueSocialWithoutSiege)
expect(siege.address).toBe('4 RUE VOLTAIRE 44000 NANTES')
describe("Si l'entreprise est retournée sans siège", () => {
const entreprise = fabriqueSocialEntrepriseAdapter(
fabriqueSocialWithoutSiege
)
it("n'a pas de siège", () => {
expect(entreprise.siège?.adresse.complète).to.equal(undefined)
})
it('a létablissement demandé', () => {
expect(entreprise.établissement.adresse.complète).to.equal(
'4 RUE VOLTAIRE 44000 NANTES'
)
})
})
})
})