init
This commit is contained in:
41
mdb-predator/.gitignore
vendored
Normal file
41
mdb-predator/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
1
mdb-predator/.npmrc
Normal file
1
mdb-predator/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
32
mdb-predator/app.json
Normal file
32
mdb-predator/app.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "MDB-PREDATOR",
|
||||
"slug": "mdb-predator",
|
||||
"scheme": "mdb-predator",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0a0a"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0a0a0a"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": ["expo-router", "expo-font"]
|
||||
}
|
||||
}
|
||||
26
mdb-predator/app/_layout.tsx
Normal file
26
mdb-predator/app/_layout.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import 'react-native-gesture-handler';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { colors } from '../src/theme/colors';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.card },
|
||||
headerTintColor: colors.text,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: 'MDB-PREDATOR' }} />
|
||||
<Stack.Screen
|
||||
name="field"
|
||||
options={{ title: 'Field visit' }}
|
||||
/>
|
||||
</Stack>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
7
mdb-predator/app/field.tsx
Normal file
7
mdb-predator/app/field.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { FieldVisitChecklist } from '../src/components/FieldVisitChecklist';
|
||||
|
||||
const DEMO_PROPERTY_ID = 'local-demo';
|
||||
|
||||
export default function FieldScreen() {
|
||||
return <FieldVisitChecklist propertyId={DEMO_PROPERTY_ID} />;
|
||||
}
|
||||
146
mdb-predator/app/index.tsx
Normal file
146
mdb-predator/app/index.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { runFinancialAgent } from '../src/agents/orchestrator';
|
||||
import { analyzeDeal, RED_FLAG_MARGIN_PCT, type DealAnalysisInput } from '../src/core/dealAnalysis';
|
||||
import { sharePurchaseOfferPdf } from '../src/services/offerPdf';
|
||||
import { colors } from '../src/theme/colors';
|
||||
|
||||
const DEMO_PROPERTY_ID = 'local-demo';
|
||||
|
||||
export default function RedFlagDashboard() {
|
||||
const [asking, setAsking] = useState('185000');
|
||||
const [resale, setResale] = useState('268000');
|
||||
const [surface, setSurface] = useState('88');
|
||||
const [works, setWorks] = useState('42000');
|
||||
|
||||
const input: DealAnalysisInput = useMemo(
|
||||
() => ({
|
||||
resaleEstimateTtc: Number(resale.replace(/\s/g, '')) || 0,
|
||||
surfaceM2: Number(surface.replace(',', '.')) || 1,
|
||||
worksTotalEur: Number(works.replace(/\s/g, '')) || 0,
|
||||
notaryRateOnPurchase: 0.077,
|
||||
saleAgencyRateOnResale: 0.05,
|
||||
miscAcquisitionEur: 2500,
|
||||
miscSaleEur: 1800,
|
||||
holdingMonths: 6,
|
||||
holdingAnnualRateOnPrincipal: 0.055,
|
||||
}),
|
||||
[resale, surface, works],
|
||||
);
|
||||
|
||||
const analysis = useMemo(() => analyzeDeal(input), [input]);
|
||||
const askNum = Number(asking.replace(/\s/g, '')) || 0;
|
||||
const fin = useMemo(
|
||||
() => runFinancialAgent(input, askNum),
|
||||
[input, askNum],
|
||||
);
|
||||
const red = analysis.isRedFlag(askNum);
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.scroll}>
|
||||
<View style={[styles.banner, red ? styles.bannerBad : styles.bannerOk]}>
|
||||
<Text style={styles.bannerTitle}>{red ? 'RED FLAG' : 'GO'}</Text>
|
||||
<Text style={styles.bannerSub}>
|
||||
Marge nette à prix affiché : {(analysis.netMarginPct(askNum) * 100).toFixed(1)} %
|
||||
{' — '}seuil {(RED_FLAG_MARGIN_PCT * 100).toFixed(0)} %
|
||||
</Text>
|
||||
<Text style={styles.bannerSub}>
|
||||
Prix d’achat max (algo) :{' '}
|
||||
{fin.maxBuyingPriceEur.toLocaleString('fr-FR')} €
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.h}>Hypothèses deal</Text>
|
||||
<Field label="Prix affiché / offre actuelle (€)" value={asking} onChange={setAsking} />
|
||||
<Field label="Prix de revente estimé TTC (€)" value={resale} onChange={setResale} />
|
||||
<Field label="Surface (m²)" value={surface} onChange={setSurface} />
|
||||
<Field label="Travaux estimés (€)" value={works} onChange={setWorks} />
|
||||
|
||||
<Pressable style={styles.btn} onPress={() => router.push('/field')}>
|
||||
<Text style={styles.btnText}>Field visit checklist</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnGhost]}
|
||||
onPress={() =>
|
||||
void sharePurchaseOfferPdf({
|
||||
propertyTitle: 'Bien cible',
|
||||
address: 'À compléter',
|
||||
maxBuyPriceEur: fin.maxBuyingPriceEur,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text style={styles.btnTextGhost}>One-click offer (PDF)</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.foot}>
|
||||
Agents SCOUT / ENGINEER / APIs : brancher Edge Functions + clés serveur.
|
||||
Dossier checklist : id « {DEMO_PROPERTY_ID} ».
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<Text style={styles.lab}>{label}</Text>
|
||||
<TextInput
|
||||
keyboardType="decimal-pad"
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
style={styles.inp}
|
||||
placeholderTextColor={colors.muted}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { padding: 16, paddingBottom: 48 },
|
||||
banner: { borderRadius: 16, padding: 18, marginBottom: 20 },
|
||||
bannerBad: { backgroundColor: '#3a121c' },
|
||||
bannerOk: { backgroundColor: '#0f2a1c' },
|
||||
bannerTitle: { color: colors.text, fontSize: 28, fontWeight: '900' },
|
||||
bannerSub: { color: colors.muted, marginTop: 8, lineHeight: 20 },
|
||||
h: { color: colors.text, fontSize: 16, fontWeight: '700', marginBottom: 10 },
|
||||
lab: { color: colors.muted, fontSize: 12, marginBottom: 6 },
|
||||
inp: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
btn: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
btnGhost: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||
btnTextGhost: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
foot: { color: colors.muted, marginTop: 24, fontSize: 12, lineHeight: 18 },
|
||||
});
|
||||
BIN
mdb-predator/assets/adaptive-icon.png
Normal file
BIN
mdb-predator/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
mdb-predator/assets/favicon.png
Normal file
BIN
mdb-predator/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
mdb-predator/assets/icon.png
Normal file
BIN
mdb-predator/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
mdb-predator/assets/splash-icon.png
Normal file
BIN
mdb-predator/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
7
mdb-predator/babel.config.js
Normal file
7
mdb-predator/babel.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['expo-router/babel'],
|
||||
};
|
||||
};
|
||||
9378
mdb-predator/package-lock.json
generated
Normal file
9378
mdb-predator/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
mdb-predator/package.json
Normal file
38
mdb-predator/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "mdb-predator",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"expo": "~54.0.33",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-print": "~15.0.8",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-web": "^0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
63
mdb-predator/src/agents/orchestrator.ts
Normal file
63
mdb-predator/src/agents/orchestrator.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Orchestration multi-agents — appels OpenAI / Edge Functions à brancher ici.
|
||||
* Les implémentations réelles ne doivent pas exposer la clé API dans l'app mobile.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DealMatch,
|
||||
EngineerEstimate,
|
||||
FinancialSnapshot,
|
||||
ScoutSignal,
|
||||
} from './types';
|
||||
import { analyzeDeal, type DealAnalysisInput } from '../core/dealAnalysis';
|
||||
|
||||
const DISTRESS = [
|
||||
'succession',
|
||||
'urgent',
|
||||
'travaux',
|
||||
'dpe g',
|
||||
'sous compromis',
|
||||
'divorce',
|
||||
];
|
||||
|
||||
export function runScoutStub(listingText: string): ScoutSignal {
|
||||
const lower = listingText.toLowerCase();
|
||||
const hits = DISTRESS.filter((k) => lower.includes(k));
|
||||
return {
|
||||
source: 'manual',
|
||||
distressKeywords: hits,
|
||||
score: Math.min(100, hits.length * 25 + 10),
|
||||
};
|
||||
}
|
||||
|
||||
export function runEngineerStub(surfaceM2: number, tier: 'light' | 'medium' | 'heavy'): EngineerEstimate {
|
||||
const perM2 = tier === 'light' ? 450 : tier === 'medium' ? 950 : 1800;
|
||||
const base = surfaceM2 * perM2;
|
||||
return {
|
||||
photoRefs: [],
|
||||
notes: tier,
|
||||
worksLowEur: Math.round(base * 0.85),
|
||||
worksHighEur: Math.round(base * 1.15),
|
||||
costPerM2Assumption: perM2,
|
||||
};
|
||||
}
|
||||
|
||||
export function runFinancialAgent(
|
||||
input: DealAnalysisInput,
|
||||
askingPriceTtc: number,
|
||||
): FinancialSnapshot {
|
||||
const r = analyzeDeal(input);
|
||||
return {
|
||||
maxBuyingPriceEur: r.maxBuyingPriceEur,
|
||||
netMarginPctAtAsk: r.netMarginPct(askingPriceTtc),
|
||||
redFlag: r.isRedFlag(askingPriceTtc),
|
||||
};
|
||||
}
|
||||
|
||||
export function runDealMakerStub(buyers: { id: string; name: string }[]): DealMatch[] {
|
||||
return buyers.slice(0, 5).map((b, i) => ({
|
||||
buyerId: b.id,
|
||||
buyerName: b.name,
|
||||
fitScore: 90 - i * 5,
|
||||
}));
|
||||
}
|
||||
28
mdb-predator/src/agents/types.ts
Normal file
28
mdb-predator/src/agents/types.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export type AgentId = 'SCOUT' | 'ENGINEER' | 'FINANCIAL' | 'DEAL_MAKER';
|
||||
|
||||
export interface ScoutSignal {
|
||||
source: 'apify' | 'manual' | 'zendesk';
|
||||
listingUrl?: string;
|
||||
distressKeywords: string[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface EngineerEstimate {
|
||||
photoRefs: string[];
|
||||
notes: string;
|
||||
worksLowEur: number;
|
||||
worksHighEur: number;
|
||||
costPerM2Assumption: number;
|
||||
}
|
||||
|
||||
export interface FinancialSnapshot {
|
||||
maxBuyingPriceEur: number;
|
||||
netMarginPctAtAsk: number;
|
||||
redFlag: boolean;
|
||||
}
|
||||
|
||||
export interface DealMatch {
|
||||
buyerId: string;
|
||||
buyerName: string;
|
||||
fitScore: number;
|
||||
}
|
||||
150
mdb-predator/src/components/FieldVisitChecklist.tsx
Normal file
150
mdb-predator/src/components/FieldVisitChecklist.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { loadFieldVisit, saveFieldVisit } from '../offline/fieldVisitStorage';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
export type FieldItemKey = 'roof' | 'structure' | 'humidity' | 'electricity';
|
||||
|
||||
export interface FieldChecklistState {
|
||||
items: Record<FieldItemKey, boolean>;
|
||||
notes: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const ITEMS: { key: FieldItemKey; label: string; hint: string }[] = [
|
||||
{ key: 'roof', label: 'Toiture', hint: 'Fuites, charpente, couverture' },
|
||||
{ key: 'structure', label: 'Structure', hint: 'Murs porteurs, plancher, fissures' },
|
||||
{ key: 'humidity', label: 'Humidité', hint: 'Infiltrations, remontées, cave' },
|
||||
{ key: 'electricity', label: 'Électricité', hint: 'Tableau, mise aux normes' },
|
||||
];
|
||||
|
||||
const defaultState = (): FieldChecklistState => ({
|
||||
items: {
|
||||
roof: false,
|
||||
structure: false,
|
||||
humidity: false,
|
||||
electricity: false,
|
||||
},
|
||||
notes: '',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
propertyId: string;
|
||||
};
|
||||
|
||||
export function FieldVisitChecklist({ propertyId }: Props) {
|
||||
const [state, setState] = useState<FieldChecklistState>(defaultState);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const saved = await loadFieldVisit(propertyId);
|
||||
if (!cancelled) {
|
||||
if (saved) setState(saved);
|
||||
setHydrated(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [propertyId]);
|
||||
|
||||
const persist = useCallback(
|
||||
async (next: FieldChecklistState) => {
|
||||
const stamped = { ...next, updatedAt: new Date().toISOString() };
|
||||
setState(stamped);
|
||||
await saveFieldVisit(propertyId, stamped);
|
||||
},
|
||||
[propertyId],
|
||||
);
|
||||
|
||||
const toggle = (key: FieldItemKey, value: boolean) => {
|
||||
void persist({
|
||||
...state,
|
||||
items: { ...state.items, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.muted}>Chargement checklist…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.scroll}>
|
||||
<Text style={styles.title}>Field mode</Text>
|
||||
<Text style={styles.sub}>
|
||||
Hors-ligne : les coches sont enregistrées sur l’appareil (AsyncStorage).
|
||||
</Text>
|
||||
{ITEMS.map((row) => (
|
||||
<View key={row.key} style={styles.row}>
|
||||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||
<Text style={styles.label}>{row.label}</Text>
|
||||
<Text style={styles.hint}>{row.hint}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={state.items[row.key]}
|
||||
onValueChange={(v) => toggle(row.key, v)}
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
<Text style={styles.section}>Notes terrain</Text>
|
||||
<TextInput
|
||||
style={styles.notes}
|
||||
multiline
|
||||
placeholder="Observations, photos référencées…"
|
||||
placeholderTextColor={colors.muted}
|
||||
value={state.notes}
|
||||
onChangeText={(t) => void persist({ ...state, notes: t })}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
muted: { color: colors.muted },
|
||||
scroll: { padding: 16, paddingBottom: 40 },
|
||||
title: { color: colors.text, fontSize: 22, fontWeight: '800', marginBottom: 6 },
|
||||
sub: { color: colors.muted, marginBottom: 20, lineHeight: 20 },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
label: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
hint: { color: colors.muted, fontSize: 13, marginTop: 4 },
|
||||
section: {
|
||||
color: colors.muted,
|
||||
marginTop: 20,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.08,
|
||||
},
|
||||
notes: {
|
||||
minHeight: 120,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.card,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
});
|
||||
148
mdb-predator/src/core/dealAnalysis.ts
Normal file
148
mdb-predator/src/core/dealAnalysis.ts
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* MDB-PREDATOR — Deal Analysis (Financial agent logic, client-side).
|
||||
* Résout le prix d'achat maximum pour tenir une marge nette cible après
|
||||
* frais de notaire, travaux, portage, TVA sur marge (estimation) et frais de vente.
|
||||
*/
|
||||
|
||||
export const RED_FLAG_MARGIN_PCT = 0.15;
|
||||
export const DEFAULT_VAT_ON_MARGIN_RATE = 0.2;
|
||||
|
||||
export interface DealAnalysisInput {
|
||||
/** Prix de cession estimé (TTC marché). */
|
||||
resaleEstimateTtc: number;
|
||||
surfaceM2: number;
|
||||
/** Travaux + imprévus (hors checklist terrain = déjà inclus ici si besoin). */
|
||||
worksTotalEur: number;
|
||||
notaryRateOnPurchase: number;
|
||||
saleAgencyRateOnResale: number;
|
||||
miscAcquisitionEur: number;
|
||||
miscSaleEur: number;
|
||||
holdingMonths: number;
|
||||
holdingAnnualRateOnPrincipal: number;
|
||||
vatOnMarginRate?: number;
|
||||
}
|
||||
|
||||
export interface DealAnalysisResult {
|
||||
/** Coût total engagé pour un prix d'achat donné (hors achat = frais annexes). */
|
||||
totalCashOutForPurchase: (purchaseTtc: number) => number;
|
||||
/** Marge nette après TVA sur marge (€). */
|
||||
netProfitEur: (purchaseTtc: number) => number;
|
||||
/** Marge nette / investissement total. */
|
||||
netMarginPct: (purchaseTtc: number) => number;
|
||||
/** true si marge < 15 % (RED FLAG dashboard). */
|
||||
isRedFlag: (purchaseTtc: number) => boolean;
|
||||
/** Prix d'achat max pour respecter RED_FLAG_MARGIN_PCT sur l'enveloppe investie. */
|
||||
maxBuyingPriceEur: number;
|
||||
/** Prix d'achat au m² implicite pour maxBuyingPriceEur. */
|
||||
maxBuyingPricePerM2: number;
|
||||
/** Seuil de revente TTC break-even simplifié (hors effet TVA marginale). */
|
||||
breakEvenResaleTtc: (purchaseTtc: number) => number;
|
||||
}
|
||||
|
||||
function holdingCostEur(
|
||||
principal: number,
|
||||
months: number,
|
||||
annualRate: number,
|
||||
): number {
|
||||
return principal * annualRate * (months / 12);
|
||||
}
|
||||
|
||||
function netResaleTtc(input: DealAnalysisInput): number {
|
||||
const agency = input.resaleEstimateTtc * input.saleAgencyRateOnResale;
|
||||
return input.resaleEstimateTtc - agency - input.miscSaleEur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pour un prix d'achat candidat P :
|
||||
* investissement = P + notaire(P) + travaux + frais achat + portage(P)
|
||||
* marge brute avant TVA = produit_net_revente - investissement
|
||||
* TVA sur marge ≈ taux × max(0, marge_brute)
|
||||
* marge nette = marge_brute - TVA
|
||||
*/
|
||||
export function analyzeDeal(input: DealAnalysisInput): DealAnalysisResult {
|
||||
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
|
||||
const resaleNet = netResaleTtc(input);
|
||||
|
||||
const totalCashOutForPurchase = (P: number): number => {
|
||||
const notary = P * input.notaryRateOnPurchase;
|
||||
const carry = holdingCostEur(
|
||||
P,
|
||||
input.holdingMonths,
|
||||
input.holdingAnnualRateOnPrincipal,
|
||||
);
|
||||
return P + notary + input.worksTotalEur + input.miscAcquisitionEur + carry;
|
||||
};
|
||||
|
||||
const grossBeforeVat = (P: number) => resaleNet - totalCashOutForPurchase(P);
|
||||
const vatOnMargin = (P: number) => Math.max(0, grossBeforeVat(P)) * vatRate;
|
||||
const netProfitEur = (P: number) => grossBeforeVat(P) - vatOnMargin(P);
|
||||
|
||||
const netMarginPct = (P: number) => {
|
||||
const inv = totalCashOutForPurchase(P);
|
||||
return inv > 0 ? netProfitEur(P) / inv : 0;
|
||||
};
|
||||
|
||||
const isRedFlag = (P: number) => netMarginPct(P) < RED_FLAG_MARGIN_PCT;
|
||||
|
||||
const maxBuyingPriceEur = solveMaxPurchaseForMargin(
|
||||
input,
|
||||
vatRate,
|
||||
resaleNet,
|
||||
RED_FLAG_MARGIN_PCT,
|
||||
);
|
||||
|
||||
const maxBuyingPricePerM2 =
|
||||
input.surfaceM2 > 0 ? maxBuyingPriceEur / input.surfaceM2 : 0;
|
||||
|
||||
const breakEvenResaleTtc = (purchaseTtc: number): number => {
|
||||
const inv = totalCashOutForPurchase(purchaseTtc);
|
||||
const coeff = 1 - input.saleAgencyRateOnResale;
|
||||
if (coeff <= 0) return Number.POSITIVE_INFINITY;
|
||||
return (inv + input.miscSaleEur) / coeff;
|
||||
};
|
||||
|
||||
return {
|
||||
totalCashOutForPurchase,
|
||||
netProfitEur,
|
||||
netMarginPct,
|
||||
isRedFlag,
|
||||
maxBuyingPriceEur,
|
||||
maxBuyingPricePerM2,
|
||||
breakEvenResaleTtc,
|
||||
};
|
||||
}
|
||||
|
||||
function solveMaxPurchaseForMargin(
|
||||
input: DealAnalysisInput,
|
||||
vatRate: number,
|
||||
resaleNet: number,
|
||||
targetNetMarginPct: number,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = Math.max(input.resaleEstimateTtc * 1.2, 1);
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const mid = (low + high) / 2;
|
||||
const notary = mid * input.notaryRateOnPurchase;
|
||||
const carry = holdingCostEur(
|
||||
mid,
|
||||
input.holdingMonths,
|
||||
input.holdingAnnualRateOnPrincipal,
|
||||
);
|
||||
const inv =
|
||||
mid +
|
||||
notary +
|
||||
input.worksTotalEur +
|
||||
input.miscAcquisitionEur +
|
||||
carry;
|
||||
const gross = resaleNet - inv;
|
||||
const vat = Math.max(0, gross) * vatRate;
|
||||
const net = gross - vat;
|
||||
const margin = inv > 0 ? net / inv : 0;
|
||||
if (margin >= targetNetMarginPct) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.round(low));
|
||||
}
|
||||
15
mdb-predator/src/lib/supabase.ts
Normal file
15
mdb-predator/src/lib/supabase.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const url = process.env.EXPO_PUBLIC_SUPABASE_URL ?? '';
|
||||
const key = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? '';
|
||||
|
||||
export const supabase = createClient(url, key, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
21
mdb-predator/src/offline/fieldVisitStorage.ts
Normal file
21
mdb-predator/src/offline/fieldVisitStorage.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { FieldChecklistState } from '../components/FieldVisitChecklist';
|
||||
|
||||
const key = (propertyId: string) => `mdb-predator:field:${propertyId}`;
|
||||
|
||||
export async function loadFieldVisit(propertyId: string): Promise<FieldChecklistState | null> {
|
||||
const raw = await AsyncStorage.getItem(key(propertyId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as FieldChecklistState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFieldVisit(
|
||||
propertyId: string,
|
||||
state: FieldChecklistState,
|
||||
): Promise<void> {
|
||||
await AsyncStorage.setItem(key(propertyId), JSON.stringify(state));
|
||||
}
|
||||
17
mdb-predator/src/services/apifyIntegration.ts
Normal file
17
mdb-predator/src/services/apifyIntegration.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Apify actors (SeLoger / PAP / LBC) — déclencher depuis n8n ou Edge Function.
|
||||
* Retourne uniquement des annonces pré-scorées côté cloud.
|
||||
*/
|
||||
|
||||
export interface ScrapedListing {
|
||||
url: string;
|
||||
title: string;
|
||||
priceEur: number;
|
||||
city: string;
|
||||
rawText: string;
|
||||
grade: 'A' | 'B' | 'C';
|
||||
}
|
||||
|
||||
export async function pullGradeADealsStub(): Promise<ScrapedListing[]> {
|
||||
return [];
|
||||
}
|
||||
16
mdb-predator/src/services/dvfDataGouv.ts
Normal file
16
mdb-predator/src/services/dvfDataGouv.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* DVF (data.gouv) — à appeler depuis une Edge Function Supabase pour éviter CORS
|
||||
* et mutualiser le cache. Signature prête pour l’API tabulaire.
|
||||
*/
|
||||
|
||||
export interface DvfStreetContext {
|
||||
inseeCode: string;
|
||||
streetNormalized: string;
|
||||
yearMin?: number;
|
||||
}
|
||||
|
||||
export async function fetchDvfMedianPriceM2Stub(
|
||||
_ctx: DvfStreetContext,
|
||||
): Promise<number | null> {
|
||||
return null;
|
||||
}
|
||||
32
mdb-predator/src/services/offerPdf.ts
Normal file
32
mdb-predator/src/services/offerPdf.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import * as Print from 'expo-print';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export async function sharePurchaseOfferPdf(params: {
|
||||
propertyTitle: string;
|
||||
address: string;
|
||||
maxBuyPriceEur: number;
|
||||
sellerName?: string;
|
||||
}): Promise<void> {
|
||||
const html = `<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"/>
|
||||
<style>body{font-family:system-ui;padding:32px;color:#111}
|
||||
h1{font-size:22px} .box{border:1px solid #ccc;padding:16px;border-radius:8px;margin-top:16px}
|
||||
</style></head><body>
|
||||
<h1>Offre d'achat — ${escape(params.propertyTitle)}</h1>
|
||||
<p>${escape(params.address)}</p>
|
||||
<div class="box">
|
||||
<p>Montant de l'offre : <strong>${params.maxBuyPriceEur.toLocaleString('fr-FR')} €</strong></p>
|
||||
<p>(${params.sellerName ? `Destinataire : ${escape(params.sellerName)}` : 'À compléter'})</p>
|
||||
</div>
|
||||
<p style="margin-top:24px;font-size:11px;color:#666">MDB-PREDATOR — modèle indicatif, valider avec votre notaire / juriste.</p>
|
||||
</body></html>`;
|
||||
const { uri } = await Print.printToFileAsync({ html });
|
||||
if (Platform.OS === 'web') return;
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(uri, { mimeType: 'application/pdf', dialogTitle: 'Offre d’achat' });
|
||||
}
|
||||
}
|
||||
|
||||
function escape(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
}
|
||||
10
mdb-predator/src/services/openStreetMap.ts
Normal file
10
mdb-predator/src/services/openStreetMap.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/** Nominatim / OSM — respecter la politique d’usage ; préférer backend pour prod. */
|
||||
|
||||
export interface GeoPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export async function reverseGeocodeStub(_p: GeoPoint): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
5
mdb-predator/src/services/pappers.ts
Normal file
5
mdb-predator/src/services/pappers.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/** Pappers — surveillance SCI / procédures (clé API côté serveur uniquement). */
|
||||
|
||||
export async function searchCompanySignalsStub(_siren: string): Promise<unknown[]> {
|
||||
return [];
|
||||
}
|
||||
10
mdb-predator/src/theme/colors.ts
Normal file
10
mdb-predator/src/theme/colors.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const colors = {
|
||||
bg: '#070b10',
|
||||
card: '#101820',
|
||||
border: '#1f2a36',
|
||||
text: '#f2f6fb',
|
||||
muted: '#8b9bb0',
|
||||
accent: '#ff4d6d',
|
||||
danger: '#ff3355',
|
||||
ok: '#3ecf8e',
|
||||
};
|
||||
@ -0,0 +1,100 @@
|
||||
-- MDB-PREDATOR — cœur données (Supabase)
|
||||
|
||||
create type public.property_status as enum (
|
||||
'sourcing',
|
||||
'visited',
|
||||
'under_offer',
|
||||
'sold'
|
||||
);
|
||||
|
||||
create table if not exists public.properties (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users (id) on delete cascade,
|
||||
title text not null default 'Property',
|
||||
address_line text,
|
||||
city text,
|
||||
postal_code text,
|
||||
insee_code text,
|
||||
latitude double precision,
|
||||
longitude double precision,
|
||||
surface_m2 numeric(12, 2),
|
||||
status public.property_status not null default 'sourcing',
|
||||
asking_price_eur numeric(14, 2),
|
||||
resale_estimate_eur numeric(14, 2),
|
||||
dvf_street_median_m2 numeric(14, 2),
|
||||
works_estimate_eur numeric(14, 2) not null default 0,
|
||||
distress_score smallint,
|
||||
scout_payload jsonb,
|
||||
engineer_payload jsonb,
|
||||
financial_payload jsonb,
|
||||
deal_maker_payload jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists properties_user_idx on public.properties (user_id);
|
||||
create index if not exists properties_status_idx on public.properties (status);
|
||||
|
||||
create table if not exists public.renovation_templates (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
label text not null,
|
||||
tier text not null check (tier in ('light', 'medium', 'heavy')),
|
||||
cost_per_m2_eur numeric(12, 2) not null,
|
||||
default_misc_eur numeric(12, 2) not null default 0,
|
||||
sort_order int not null default 0
|
||||
);
|
||||
|
||||
insert into public.renovation_templates (slug, label, tier, cost_per_m2_eur, default_misc_eur, sort_order)
|
||||
values
|
||||
('cosmetic', 'Rafraîchissement / léger', 'light', 450, 5000, 10),
|
||||
('standard', 'Rénovation standard', 'medium', 950, 12000, 20),
|
||||
('structural', 'Lourd / structure + toiture', 'heavy', 1800, 35000, 30)
|
||||
on conflict (slug) do nothing;
|
||||
|
||||
create table if not exists public.buyers_portfolio (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users (id) on delete cascade,
|
||||
display_name text not null,
|
||||
budget_min_eur numeric(14, 2),
|
||||
budget_max_eur numeric(14, 2),
|
||||
sectors jsonb,
|
||||
min_yield_pct numeric(6, 3),
|
||||
min_net_margin_pct numeric(5, 2) default 12,
|
||||
notes text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists buyers_user_idx on public.buyers_portfolio (user_id);
|
||||
|
||||
create or replace function public.predator_set_updated_at()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists tr_properties_updated on public.properties;
|
||||
create trigger tr_properties_updated
|
||||
before update on public.properties
|
||||
for each row execute procedure public.predator_set_updated_at();
|
||||
|
||||
drop trigger if exists tr_buyers_updated on public.buyers_portfolio;
|
||||
create trigger tr_buyers_updated
|
||||
before update on public.buyers_portfolio
|
||||
for each row execute procedure public.predator_set_updated_at();
|
||||
|
||||
alter table public.properties enable row level security;
|
||||
alter table public.buyers_portfolio enable row level security;
|
||||
alter table public.renovation_templates enable row level security;
|
||||
|
||||
create policy properties_owner on public.properties
|
||||
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
|
||||
|
||||
create policy buyers_owner on public.buyers_portfolio
|
||||
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
|
||||
|
||||
create policy renovation_read on public.renovation_templates
|
||||
for select to authenticated using (true);
|
||||
6
mdb-predator/tsconfig.json
Normal file
6
mdb-predator/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user