Compare commits
8 Commits
432fcbc503
...
ca19649b6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca19649b6a | ||
|
|
65bb5cd80a | ||
|
|
43b5fc03fd | ||
|
|
11a76e0292 | ||
|
|
a649b629e4 | ||
|
|
3efa83bc44 | ||
|
|
ef804b3896 | ||
|
|
f561a73b01 |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extensions>
|
||||
<!-- Override Maven's internal Guice with a Java 25 compatible version -->
|
||||
<extension>
|
||||
<groupId>com.google.inject</groupId>
|
||||
<artifactId>guice</artifactId>
|
||||
<version>7.0.0</version>
|
||||
</extension>
|
||||
</extensions>
|
||||
@@ -1,5 +0,0 @@
|
||||
--add-opens=java.base/java.lang=ALL-UNNAMED
|
||||
--add-opens=java.base/jdk.internal.misc=ALL-UNNAMED
|
||||
--add-exports=java.base/sun.nio.ch=ALL-UNNAMED
|
||||
-Djdk.module.illegalAccess=deny
|
||||
-XX:+IgnoreUnrecognizedVMOptions
|
||||
@@ -1,117 +0,0 @@
|
||||
/*
|
||||
* Copyright 2007-present the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import java.net.*;
|
||||
import java.io.*;
|
||||
import java.nio.channels.*;
|
||||
import java.util.Properties;
|
||||
|
||||
public class MavenWrapperDownloader {
|
||||
|
||||
private static final String WRAPPER_VERSION = "0.5.6";
|
||||
/**
|
||||
* Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
|
||||
*/
|
||||
private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/"
|
||||
+ WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar";
|
||||
|
||||
/**
|
||||
* Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
|
||||
* use instead of the default one.
|
||||
*/
|
||||
private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
|
||||
".mvn/wrapper/maven-wrapper.properties";
|
||||
|
||||
/**
|
||||
* Path where the maven-wrapper.jar will be saved to.
|
||||
*/
|
||||
private static final String MAVEN_WRAPPER_JAR_PATH =
|
||||
".mvn/wrapper/maven-wrapper.jar";
|
||||
|
||||
/**
|
||||
* Name of the property which should be used to override the default download url for the wrapper.
|
||||
*/
|
||||
private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
|
||||
|
||||
public static void main(String args[]) {
|
||||
System.out.println("- Downloader started");
|
||||
File baseDirectory = new File(args[0]);
|
||||
System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
|
||||
|
||||
// If the maven-wrapper.properties exists, read it and check if it contains a custom
|
||||
// wrapperUrl parameter.
|
||||
File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
|
||||
String url = DEFAULT_DOWNLOAD_URL;
|
||||
if(mavenWrapperPropertyFile.exists()) {
|
||||
FileInputStream mavenWrapperPropertyFileInputStream = null;
|
||||
try {
|
||||
mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
|
||||
Properties mavenWrapperProperties = new Properties();
|
||||
mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
|
||||
url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
|
||||
} catch (IOException e) {
|
||||
System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
|
||||
} finally {
|
||||
try {
|
||||
if(mavenWrapperPropertyFileInputStream != null) {
|
||||
mavenWrapperPropertyFileInputStream.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Ignore ...
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println("- Downloading from: " + url);
|
||||
|
||||
File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
|
||||
if(!outputFile.getParentFile().exists()) {
|
||||
if(!outputFile.getParentFile().mkdirs()) {
|
||||
System.out.println(
|
||||
"- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'");
|
||||
}
|
||||
}
|
||||
System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
|
||||
try {
|
||||
downloadFileFromURL(url, outputFile);
|
||||
System.out.println("Done");
|
||||
System.exit(0);
|
||||
} catch (Throwable e) {
|
||||
System.out.println("- Error downloading");
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void downloadFileFromURL(String urlString, File destination) throws Exception {
|
||||
if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) {
|
||||
String username = System.getenv("MVNW_USERNAME");
|
||||
char[] password = System.getenv("MVNW_PASSWORD").toCharArray();
|
||||
Authenticator.setDefault(new Authenticator() {
|
||||
@Override
|
||||
protected PasswordAuthentication getPasswordAuthentication() {
|
||||
return new PasswordAuthentication(username, password);
|
||||
}
|
||||
});
|
||||
}
|
||||
URL website = new URL(urlString);
|
||||
ReadableByteChannel rbc;
|
||||
rbc = Channels.newChannel(website.openStream());
|
||||
FileOutputStream fos = new FileOutputStream(destination);
|
||||
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
|
||||
fos.close();
|
||||
rbc.close();
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3/apache-maven-3-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
|
||||
136
README.md
136
README.md
@@ -24,9 +24,22 @@ All dependencies are managed via Maven (see `pom.xml`):
|
||||
- **JavaMail 1.6.2** - Email notifications (free)
|
||||
- **OpenCV 4.9.0** - Image processing and object detection
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Development: Sync Production Data
|
||||
|
||||
To work with real production data locally:
|
||||
|
||||
```powershell
|
||||
# Linux/Mac (Bash)
|
||||
./scripts/sync-production-data.sh --db-only
|
||||
```
|
||||
|
||||
See [scripts/README.md](scripts/README.md) for full documentation.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1.. Notification Options (Choose One)
|
||||
### 1. Notification Options (Choose One)
|
||||
|
||||
#### Option A: Desktop Notifications Only ⭐ (Recommended - Zero Setup)
|
||||
|
||||
@@ -135,6 +148,10 @@ mvn exec:java -Dexec.mainClass="com.auction.scraper.TroostwijkScraper"
|
||||
|
||||
## System Architecture & Integration Flow
|
||||
|
||||
> **📊 Complete Integration Flowchart**: See [docs/INTEGRATION_FLOWCHART.md](docs/INTEGRATION_FLOWCHART.md) for the detailed intelligence integration diagram with GraphQL API fields, analytics, and dashboard features.
|
||||
|
||||
### Quick Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ COMPLETE SYSTEM INTEGRATION DIAGRAM │
|
||||
@@ -163,7 +180,7 @@ mvn exec:java -Dexec.mainClass="com.auction.scraper.TroostwijkScraper"
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ SQLITE DATABASE │
|
||||
│ troostwijk.db │
|
||||
│ output/cache.db │
|
||||
└──────────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
@@ -329,19 +346,112 @@ mvn exec:java -Dexec.mainClass="com.auction.scraper.TroostwijkScraper"
|
||||
value > threshold]
|
||||
|
||||
```
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph P1["PHASE 1: EXTERNAL SCRAPER (Python/Playwright)"]
|
||||
direction LR
|
||||
A1[Listing Pages<br/>/auctions?page=N] --> A2[Extract URLs]
|
||||
B1[Auction Pages<br/>/a/auction-id] --> B2[Parse __NEXT_DATA__ JSON]
|
||||
C1[Lot Pages<br/>/l/lot-id] --> C2[Parse __NEXT_DATA__ JSON]
|
||||
|
||||
A2 --> D1[INSERT auctions to SQLite]
|
||||
B2 --> D1
|
||||
C2 --> D2[INSERT lots & image URLs]
|
||||
|
||||
D1 --> DB[(SQLite Database<br/>output/cache.db)]
|
||||
D2 --> DB
|
||||
end
|
||||
|
||||
DB --> P2_Entry
|
||||
|
||||
subgraph P2["PHASE 2: MONITORING & PROCESSING (Java)"]
|
||||
direction TB
|
||||
P2_Entry[Data Ready] --> Monitor[TroostwijkMonitor<br/>Read lots every hour]
|
||||
P2_Entry --> DBService[DatabaseService<br/>Query & Import]
|
||||
P2_Entry --> Adapter[ScraperDataAdapter<br/>Transform TEXT → INTEGER]
|
||||
|
||||
Monitor --> BM[Bid Monitoring<br/>Check API every 1h]
|
||||
DBService --> IP[Image Processing<br/>Download & Analyze]
|
||||
Adapter --> DataForNotify[Formatted Data]
|
||||
|
||||
BM --> BidUpdate{New bid?}
|
||||
BidUpdate -->|Yes| UpdateDB[Update current_bid in DB]
|
||||
UpdateDB --> NotifyTrigger1
|
||||
|
||||
IP --> Detection[Object Detection<br/>YOLO/OpenCV DNN]
|
||||
Detection --> ObjectCheck{Detect objects?}
|
||||
ObjectCheck -->|Vehicle| Save1[Save labels & estimate value]
|
||||
ObjectCheck -->|Furniture| Save2[Save labels & estimate value]
|
||||
ObjectCheck -->|Machinery| Save3[Save labels & estimate value]
|
||||
Save1 --> NotifyTrigger2
|
||||
Save2 --> NotifyTrigger2
|
||||
Save3 --> NotifyTrigger2
|
||||
|
||||
CA[Closing Alerts<br/>Check < 5 min] --> TimeCheck{Time critical?}
|
||||
TimeCheck -->|Yes| NotifyTrigger3
|
||||
end
|
||||
|
||||
NotifyTrigger1 --> NS
|
||||
NotifyTrigger2 --> NS
|
||||
NotifyTrigger3 --> NS
|
||||
|
||||
subgraph P3["PHASE 3: NOTIFICATION SYSTEM"]
|
||||
NS[NotificationService] --> DN[Desktop Notify<br/>Windows/macOS/Linux]
|
||||
NS --> EN[Email Notify<br/>Gmail SMTP]
|
||||
NS --> PL[Set Priority Level<br/>0=Normal, 1=High]
|
||||
end
|
||||
|
||||
DN --> UI[User Interaction & Decisions]
|
||||
EN --> UI
|
||||
PL --> UI
|
||||
|
||||
subgraph UI_Details[User Decision Points / Trigger Events]
|
||||
direction LR
|
||||
E1["1. BID CHANGE<br/>'Nieuw bod op kavel 12345...'<br/>Actions: Place bid? Monitor? Ignore?"]
|
||||
E2["2. OBJECT DETECTED<br/>'Lot contains: Vehicle...'<br/>Actions: Review? Confirm value?"]
|
||||
E3["3. CLOSING ALERT<br/>'Kavel 12345 sluit binnen 5 min.'<br/>Actions: Place final bid? Let expire?"]
|
||||
E4["4. VIEWING DAY QUESTIONS<br/>'Bezichtiging op [date]...'"]
|
||||
E5["5. ITEM RECOGNITION CONFIRMATION<br/>'Detected: [object]...'"]
|
||||
E6["6. VALUE ESTIMATE APPROVAL<br/>'Geschatte waarde: €X...'"]
|
||||
E7["7. EXCEPTION HANDLING<br/>'Afwijkende sluitingstijd...'"]
|
||||
end
|
||||
|
||||
UI --> UI_Details
|
||||
|
||||
%% Object Detection Sub-Flow Detail
|
||||
subgraph P2_Detail["Object Detection & Value Estimation Pipeline"]
|
||||
direction LR
|
||||
DI[Downloaded Image] --> IPS[ImageProcessingService]
|
||||
IPS --> ODS[ObjectDetectionService]
|
||||
ODS --> Load[Load YOLO model]
|
||||
ODS --> Run[Run inference]
|
||||
ODS --> Post[Post-process detections<br/>confidence > 0.5]
|
||||
Post --> ObjList["Detected Objects List<br/>(80 COCO classes)"]
|
||||
ObjList --> VEL[Value Estimation Logic<br/>Future enhancement]
|
||||
VEL --> Match[Match to categories]
|
||||
VEL --> History[Historical price analysis]
|
||||
VEL --> Condition[Condition assessment]
|
||||
VEL --> Market[Market trends]
|
||||
Market --> ValueEst["Estimated Value Range<br/>Confidence: 75%"]
|
||||
ValueEst --> SaveToDB[Save to Database]
|
||||
SaveToDB --> TriggerNotify{Value > threshold?}
|
||||
end
|
||||
|
||||
IP -.-> P2_Detail
|
||||
TriggerNotify -.-> NotifyTrigger2
|
||||
```
|
||||
## Integration Hooks & Timing
|
||||
|
||||
| Event | Frequency | Trigger | Notification Type | User Action Required |
|
||||
|-------|-----------|---------|-------------------|---------------------|
|
||||
| **New auction discovered** | On scrape | Scraper finds new auction | Desktop + Email (optional) | Review auction |
|
||||
| **Bid change detected** | Every 1 hour | Monitor detects higher bid | Desktop + Email | Place counter-bid? |
|
||||
| **Closing soon (< 30 min)** | When detected | Time-based check | Desktop + Email | Review lot |
|
||||
| **Closing imminent (< 5 min)** | When detected | Time-based check | Desktop + Email (HIGH) | Final bid decision |
|
||||
| **Object detected** | On image process | YOLO finds objects | Desktop + Email | Confirm identification |
|
||||
| **Value estimated** | After detection | Estimation complete | Desktop + Email | Approve estimate |
|
||||
| **Viewing day scheduled** | From lot metadata | Scraper extracts date | Desktop + Email | Confirm attendance |
|
||||
| **Exception/Change** | On update | Scraper detects change | Desktop + Email (HIGH) | Acknowledge |
|
||||
| Event | Frequency | Trigger | Notification Type | User Action Required |
|
||||
|--------------------------------|-------------------|----------------------------|----------------------------|------------------------|
|
||||
| **New auction discovered** | On scrape | Scraper finds new auction | Desktop + Email (optional) | Review auction |
|
||||
| **Bid change detected** | Every 1 hour | Monitor detects higher bid | Desktop + Email | Place counter-bid? |
|
||||
| **Closing soon (< 30 min)** | When detected | Time-based check | Desktop + Email | Review lot |
|
||||
| **Closing imminent (< 5 min)** | When detected | Time-based check | Desktop + Email (HIGH) | Final bid decision |
|
||||
| **Object detected** | On image process | YOLO finds objects | Desktop + Email | Confirm identification |
|
||||
| **Value estimated** | After detection | Estimation complete | Desktop + Email | Approve estimate |
|
||||
| **Viewing day scheduled** | From lot metadata | Scraper extracts date | Desktop + Email | Confirm attendance |
|
||||
| **Exception/Change** | On update | Scraper detects change | Desktop + Email (HIGH) | Acknowledge |
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -444,7 +554,7 @@ Kavel 12345 sluit binnen 5 min.
|
||||
|
||||
|
||||
```shell
|
||||
git add . | git commit -a -m all | git push
|
||||
ssh tour@athena.lan "docker run --rm -v shared-auction-data:/data -v /tmp:/tmp alpine cp /data/cache.db /tmp/cache.db" && scp tour@athena.lan:/tmp/cache.db c:/mnt/okcomputer/cache.db
|
||||
```
|
||||
## License
|
||||
|
||||
|
||||
192
docs/DATABASE_CLEANUP_GUIDE.md
Normal file
192
docs/DATABASE_CLEANUP_GUIDE.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Database Cleanup Guide
|
||||
|
||||
## Problem: Mixed Data Formats
|
||||
|
||||
Your production database (`cache.db`) contains data from two different scrapers:
|
||||
|
||||
### Valid Data (99.92%)
|
||||
- **Format**: `A1-34732-49` (lot_id) + `c1f44ec2-ad6e-4c98-b0e2-cb1d8ccddcab` (auction_id UUID)
|
||||
- **Count**: 16,794 lots
|
||||
- **Source**: Current GraphQL-based scraper
|
||||
- **Status**: ✅ Clean, with proper auction_id
|
||||
|
||||
### Invalid Data (0.08%)
|
||||
- **Format**: `bmw-550i-4-4-v8-high-executive-...` (slug as lot_id) + `""` (empty auction_id)
|
||||
- **Count**: 13 lots
|
||||
- **Source**: Old legacy scraper
|
||||
- **Status**: ❌ Missing auction_id, causes issues
|
||||
|
||||
## Impact
|
||||
|
||||
These 13 invalid entries:
|
||||
- Cause `NullPointerException` in analytics when grouping by country
|
||||
- Cannot be properly linked to auctions
|
||||
- Skew statistics slightly
|
||||
- May cause issues with intelligence features that rely on auction_id
|
||||
|
||||
## Solution 1: Clean Sync (Recommended)
|
||||
|
||||
The updated sync script now **automatically removes old local data** before syncing:
|
||||
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
.\scripts\Sync-ProductionData.ps1
|
||||
|
||||
# Linux/Mac
|
||||
./scripts/sync-production-data.sh --db-only
|
||||
```
|
||||
|
||||
**What it does**:
|
||||
1. Backs up existing database to `cache.db.backup-YYYYMMDD-HHMMSS`
|
||||
2. **Removes old local database completely**
|
||||
3. Downloads fresh copy from production
|
||||
4. Shows data quality report
|
||||
|
||||
**Output includes**:
|
||||
```
|
||||
Database statistics:
|
||||
┌─────────────┬────────┐
|
||||
│ table_name │ count │
|
||||
├─────────────┼────────┤
|
||||
│ auctions │ 526 │
|
||||
│ lots │ 16807 │
|
||||
│ images │ 536502 │
|
||||
│ cache │ 2134 │
|
||||
└─────────────┴────────┘
|
||||
|
||||
Data quality:
|
||||
┌────────────────────────────────────┬────────┬────────────┐
|
||||
│ metric │ count │ percentage │
|
||||
├────────────────────────────────────┼────────┼────────────┤
|
||||
│ Valid lots │ 16794 │ 99.92% │
|
||||
│ Invalid lots (missing auction_id) │ 13 │ 0.08% │
|
||||
│ Lots with intelligence fields │ 0 │ 0.00% │
|
||||
└────────────────────────────────────┴────────┴────────────┘
|
||||
```
|
||||
|
||||
## Solution 2: Manual Cleanup
|
||||
|
||||
If you want to clean your existing local database without re-downloading:
|
||||
|
||||
```bash
|
||||
# Dry run (see what would be deleted)
|
||||
./scripts/cleanup-database.sh --dry-run
|
||||
|
||||
# Actual cleanup
|
||||
./scripts/cleanup-database.sh
|
||||
```
|
||||
|
||||
**What it does**:
|
||||
1. Creates backup before cleanup
|
||||
2. Deletes lots with missing auction_id
|
||||
3. Deletes orphaned images (images without matching lots)
|
||||
4. Compacts database (VACUUM) to reclaim space
|
||||
5. Shows before/after statistics
|
||||
|
||||
**Example output**:
|
||||
```
|
||||
Current database state:
|
||||
┌──────────────────────────────────┬────────┐
|
||||
│ metric │ count │
|
||||
├──────────────────────────────────┼────────┤
|
||||
│ Total lots │ 16807 │
|
||||
│ Valid lots (with auction_id) │ 16794 │
|
||||
│ Invalid lots (missing auction_id) │ 13 │
|
||||
└──────────────────────────────────┴────────┘
|
||||
|
||||
Analyzing data to clean up...
|
||||
→ Invalid lots to delete: 13
|
||||
→ Orphaned images to delete: 0
|
||||
|
||||
This will permanently delete the above records.
|
||||
Continue? (y/N) y
|
||||
|
||||
Cleaning up database...
|
||||
[1/2] Deleting invalid lots...
|
||||
✓ Deleted 13 invalid lots
|
||||
[2/2] Deleting orphaned images...
|
||||
✓ Deleted 0 orphaned images
|
||||
[3/3] Compacting database...
|
||||
✓ Database compacted
|
||||
|
||||
Final database state:
|
||||
┌───────────────┬────────┐
|
||||
│ metric │ count │
|
||||
├───────────────┼────────┤
|
||||
│ Total lots │ 16794 │
|
||||
│ Total images │ 536502 │
|
||||
└───────────────┴────────┘
|
||||
|
||||
Database size: 8.9G
|
||||
```
|
||||
|
||||
## Solution 3: SQL Manual Cleanup
|
||||
|
||||
If you prefer to manually clean using SQL:
|
||||
|
||||
```sql
|
||||
-- Backup first!
|
||||
-- cp cache.db cache.db.backup
|
||||
|
||||
-- Check invalid entries
|
||||
SELECT COUNT(*), 'Invalid' as type
|
||||
FROM lots
|
||||
WHERE auction_id IS NULL OR auction_id = ''
|
||||
UNION ALL
|
||||
SELECT COUNT(*), 'Valid'
|
||||
FROM lots
|
||||
WHERE auction_id IS NOT NULL AND auction_id != '';
|
||||
|
||||
-- Delete invalid lots
|
||||
DELETE FROM lots
|
||||
WHERE auction_id IS NULL OR auction_id = '';
|
||||
|
||||
-- Delete orphaned images
|
||||
DELETE FROM images
|
||||
WHERE lot_id NOT IN (SELECT lot_id FROM lots);
|
||||
|
||||
-- Compact database
|
||||
VACUUM;
|
||||
```
|
||||
|
||||
## Prevention: Production Database Cleanup
|
||||
|
||||
To prevent these invalid entries from accumulating on production, you can:
|
||||
|
||||
1. **Clean production database** (one-time):
|
||||
```bash
|
||||
ssh tour@athena.lan
|
||||
docker run --rm -v shared-auction-data:/data alpine sqlite3 /data/cache.db "DELETE FROM lots WHERE auction_id IS NULL OR auction_id = '';"
|
||||
```
|
||||
|
||||
2. **Update scraper** to ensure all lots have auction_id
|
||||
3. **Add validation** in scraper to reject lots without auction_id
|
||||
|
||||
## When to Clean
|
||||
|
||||
### Immediately if:
|
||||
- ❌ Seeing `NullPointerException` in analytics
|
||||
- ❌ Dashboard insights failing
|
||||
- ❌ Country distribution not working
|
||||
|
||||
### Periodically:
|
||||
- 🔄 After syncing from production (if production has invalid data)
|
||||
- 🔄 Weekly/monthly maintenance
|
||||
- 🔄 Before major testing or demos
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Use Solution 1 (Clean Sync)** for simplicity:
|
||||
- ✅ Guarantees clean state
|
||||
- ✅ No manual SQL needed
|
||||
- ✅ Shows data quality report
|
||||
- ✅ Safe (automatic backup)
|
||||
|
||||
The 13 invalid entries are from an old scraper and represent only 0.08% of data, so cleaning them up has minimal impact but prevents future errors.
|
||||
|
||||
---
|
||||
|
||||
**Related Documentation**:
|
||||
- [Sync Scripts README](../scripts/README.md)
|
||||
- [Data Sync Setup](DATA_SYNC_SETUP.md)
|
||||
- [Database Architecture](../wiki/DATABASE_ARCHITECTURE.md)
|
||||
109
docs/DATA_SYNC_SETUP.md
Normal file
109
docs/DATA_SYNC_SETUP.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Production Data Sync Setup
|
||||
|
||||
Quick reference for syncing production data from `athena.lan` to your local development environment.
|
||||
|
||||
## 🚀 One-Command Setup
|
||||
|
||||
### Linux/Mac
|
||||
```bash
|
||||
./scripts/sync-production-data.sh
|
||||
```
|
||||
|
||||
## 📋 Complete Usage
|
||||
|
||||
### Bash (Linux/Mac/Git Bash)
|
||||
```bash
|
||||
# Database only
|
||||
./scripts/sync-production-data.sh --db-only
|
||||
|
||||
# Everything
|
||||
./scripts/sync-production-data.sh --all
|
||||
|
||||
# Images only
|
||||
./scripts/sync-production-data.sh --images-only
|
||||
```
|
||||
|
||||
## 🔧 What It Does
|
||||
|
||||
1. **Connects to athena.lan** via SSH
|
||||
2. **Copies database** from Docker volume to /tmp
|
||||
3. **Downloads to local** machine (c:\mnt\okcomputer\cache.db)
|
||||
4. **Backs up** existing local database automatically
|
||||
5. **Shows statistics** (auction count, lot count, etc.)
|
||||
6. **Cleans up** temporary files on remote server
|
||||
|
||||
### With Images
|
||||
- Also syncs the `/data/images/` directory
|
||||
- Uses rsync for incremental sync (if available)
|
||||
- Can be large (several GB)
|
||||
|
||||
## 📊 What You Get
|
||||
|
||||
### Database (`cache.db`)
|
||||
- **~8.9 GB** of production data
|
||||
- 16,000+ lots
|
||||
- 536,000+ images metadata
|
||||
- Full auction history
|
||||
- HTTP cache from scraper
|
||||
|
||||
### Images (`images/`)
|
||||
- Downloaded lot images
|
||||
- Organized by lot ID
|
||||
- Variable size (can be large)
|
||||
|
||||
## ⚡ Quick Workflow
|
||||
|
||||
### Daily Development
|
||||
```powershell
|
||||
# Morning: Get fresh data
|
||||
.\scripts\Sync-ProductionData.sh -Force
|
||||
|
||||
# Develop & test
|
||||
mvn quarkus:dev
|
||||
|
||||
# View dashboard
|
||||
start http://localhost:8080
|
||||
```
|
||||
|
||||
## 🔒 Safety Features
|
||||
|
||||
- ✅ **Automatic backups** before overwriting
|
||||
- ✅ **Confirmation prompts** (unless `-Force`)
|
||||
- ✅ **Error handling** with clear messages
|
||||
- ✅ **Cleanup** of temporary files
|
||||
- ✅ **Non-destructive** - production data is never modified
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "Permission denied" or SSH errors
|
||||
```bash
|
||||
# Test SSH connection
|
||||
ssh tour@athena.lan "echo OK"
|
||||
|
||||
# If fails, check your SSH key
|
||||
ssh-add -l
|
||||
```
|
||||
|
||||
### Database already exists
|
||||
- Script automatically backs up existing database
|
||||
- Backup format: `cache.db.backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
### Slow image transfer
|
||||
- Install rsync for 10x faster incremental sync
|
||||
- Or sync database only: `.\scripts\Sync-ProductionData.sh` (default)
|
||||
|
||||
## 📚 Full Documentation
|
||||
|
||||
See [scripts/README.md](../scripts/README.md) for:
|
||||
- Prerequisites
|
||||
- Performance tips
|
||||
- Automation setup
|
||||
- Detailed troubleshooting
|
||||
|
||||
## 🎯 Common Use Cases
|
||||
|
||||
**Quick Links**:
|
||||
- [Main README](../README.md)
|
||||
- [Scripts Documentation](../scripts/README.md)
|
||||
- [Integration Flowchart](INTEGRATION_FLOWCHART.md)
|
||||
- [Intelligence Features](INTELLIGENCE_FEATURES_SUMMARY.md)
|
||||
153
docs/EXPERT_ANALITICS.sql
Normal file
153
docs/EXPERT_ANALITICS.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- Extend 'lots' table
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN starting_bid DECIMAL(12, 2);
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN estimated_min DECIMAL(12, 2);
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN estimated_max DECIMAL(12, 2);
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN reserve_price DECIMAL(12, 2);
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN reserve_met BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN bid_increment DECIMAL(12, 2);
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN watch_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN view_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN first_bid_time TEXT;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN last_bid_time TEXT;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN bid_velocity DECIMAL(5, 2);
|
||||
-- bids per hour
|
||||
|
||||
-- New table: bid history (CRITICAL)
|
||||
CREATE TABLE bid_history
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lot_id TEXT REFERENCES lots (lot_id),
|
||||
bid_amount DECIMAL(12, 2) NOT NULL,
|
||||
bid_time TEXT NOT NULL,
|
||||
is_winning BOOLEAN DEFAULT FALSE,
|
||||
is_autobid BOOLEAN DEFAULT FALSE,
|
||||
bidder_id TEXT, -- anonymized
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bid_history_lot_time ON bid_history (lot_id, bid_time);
|
||||
-- Extend 'lots' table
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN condition_score DECIMAL(3, 2); -- 0.00-10.00
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN condition_description TEXT;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN year_manufactured INTEGER;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN serial_number TEXT;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN originality_score DECIMAL(3, 2); -- % original parts
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN provenance TEXT;
|
||||
ALTER TABLE lots
|
||||
ADD COLUMN comparable_lot_ids TEXT;
|
||||
-- JSON array
|
||||
|
||||
-- New table: comparable sales
|
||||
CREATE TABLE comparable_sales
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lot_id TEXT REFERENCES lots (lot_id),
|
||||
comparable_lot_id TEXT,
|
||||
similarity_score DECIMAL(3, 2), -- 0.00-1.00
|
||||
price_difference_percent DECIMAL(5, 2),
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- New table: market indices
|
||||
CREATE TABLE market_indices
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
manufacturer TEXT,
|
||||
avg_price DECIMAL(12, 2),
|
||||
median_price DECIMAL(12, 2),
|
||||
price_change_30d DECIMAL(5, 2),
|
||||
volume_change_30d DECIMAL(5, 2),
|
||||
calculated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
-- Extend 'auctions' table
|
||||
ALTER TABLE auctions
|
||||
ADD COLUMN auction_house TEXT;
|
||||
ALTER TABLE auctions
|
||||
ADD COLUMN auction_house_rating DECIMAL(3, 2);
|
||||
ALTER TABLE auctions
|
||||
ADD COLUMN buyers_premium_percent DECIMAL(5, 2);
|
||||
ALTER TABLE auctions
|
||||
ADD COLUMN payment_methods TEXT; -- JSON
|
||||
ALTER TABLE auctions
|
||||
ADD COLUMN shipping_cost_min DECIMAL(12, 2);
|
||||
ALTER TABLE auctions
|
||||
ADD COLUMN shipping_cost_max DECIMAL(12, 2);
|
||||
ALTER TABLE auctions
|
||||
ADD COLUMN seller_verified BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- New table: auction performance metrics
|
||||
CREATE TABLE auction_metrics
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
auction_id TEXT REFERENCES auctions (auction_id),
|
||||
sell_through_rate DECIMAL(5, 2),
|
||||
avg_hammer_vs_estimate DECIMAL(5, 2),
|
||||
total_hammer_price DECIMAL(15, 2),
|
||||
total_starting_price DECIMAL(15, 2),
|
||||
calculated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- New table: seasonal trends
|
||||
CREATE TABLE seasonal_trends
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
avg_price_multiplier DECIMAL(4, 2), -- vs annual avg
|
||||
volume_multiplier DECIMAL(4, 2),
|
||||
PRIMARY KEY (category, month)
|
||||
);
|
||||
-- New table: external market data
|
||||
CREATE TABLE external_market_data
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
dealer_avg_price DECIMAL(12, 2),
|
||||
retail_avg_price DECIMAL(12, 2),
|
||||
wholesale_avg_price DECIMAL(12, 2),
|
||||
source TEXT,
|
||||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- New table: image analysis results
|
||||
CREATE TABLE image_analysis
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
image_id INTEGER REFERENCES images (id),
|
||||
damage_detected BOOLEAN,
|
||||
damage_severity DECIMAL(3, 2),
|
||||
wear_level TEXT CHECK (wear_level IN ('EXCELLENT', 'GOOD', 'FAIR', 'POOR')),
|
||||
estimated_hours_used INTEGER,
|
||||
ai_confidence DECIMAL(3, 2)
|
||||
);
|
||||
|
||||
-- New table: economic indicators
|
||||
CREATE TABLE economic_indicators
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
indicator_date TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
exchange_rate DECIMAL(10, 4),
|
||||
inflation_rate DECIMAL(5, 2),
|
||||
market_volatility DECIMAL(5, 2)
|
||||
);
|
||||
38
docs/EXPERT_ANALITICS_PRIORITY.md
Normal file
38
docs/EXPERT_ANALITICS_PRIORITY.md
Normal file
@@ -0,0 +1,38 @@
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Add bid_history table] --> B[Add watch_count + estimates]
|
||||
B --> C[Create market_indices]
|
||||
C --> D[Add condition + year fields]
|
||||
D --> E[Build comparable matching]
|
||||
E --> F[Enrich with auction house data]
|
||||
F --> G[Add AI image analysis]
|
||||
```
|
||||
|
||||
| Current Practice | New Requirement | Why |
|
||||
|-----------------------|---------------------------------|---------------------------|
|
||||
| Scrape once per hour | **Scrape every bid update** | Capture velocity & timing |
|
||||
| Save only current bid | **Save full bid history** | Detect patterns & sniping |
|
||||
| Ignore watchers | **Track watch\_count** | Predict competition |
|
||||
| Skip auction metadata | **Capture house estimates** | Anchor valuations |
|
||||
| No historical data | **Store sold prices** | Train prediction models |
|
||||
| Basic text scraping | **Parse condition/serial/year** | Enable comparables |
|
||||
|
||||
|
||||
```bazaar
|
||||
Week 1-2: Foundation
|
||||
Implement bid_history scraping (most critical)
|
||||
Add watch_count, starting_bid, estimated_min/max fields
|
||||
Calculate basic bid_velocity
|
||||
Week 3-4: Valuation
|
||||
Extract year_manufactured, manufacturer, condition_description
|
||||
Create market_indices (manually or via external API)
|
||||
Build comparable lot matching logic
|
||||
Week 5-6: Intelligence Layer
|
||||
Add auction house performance tracking
|
||||
Implement undervaluation detection algorithm
|
||||
Create price alert system
|
||||
Week 7-8: Automation
|
||||
Integrate image analysis API
|
||||
Add economic indicator tracking
|
||||
Refine ML-based price predictions
|
||||
```
|
||||
126
docs/GraphQL.md
Normal file
126
docs/GraphQL.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# GraphQL Auction Schema Explorer
|
||||
|
||||
A Python script for exploring and testing GraphQL queries against the TBAuctions storefront API. This tool helps understand the auction schema by testing different query structures and viewing the responses.
|
||||
|
||||
## Features
|
||||
|
||||
- Three pre-configured GraphQL queries with varying levels of detail
|
||||
- Asynchronous HTTP requests using aiohttp for efficient testing
|
||||
- Error handling and formatted JSON output
|
||||
- Configurable auction ID, locale, and platform parameters
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.7 or higher
|
||||
- Required packages: `aiohttp`
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone or download this script
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install aiohttp
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the script directly:
|
||||
|
||||
```bash
|
||||
python auction_explorer.py
|
||||
```
|
||||
|
||||
Or make it executable and run:
|
||||
|
||||
```bash
|
||||
chmod +x auction_explorer.py
|
||||
./auction_explorer.py
|
||||
```
|
||||
|
||||
## Queries Included
|
||||
|
||||
The script tests three different query structures:
|
||||
|
||||
### 1. `viewingDays_simple`
|
||||
Basic query that retrieves city and country code for viewing days.
|
||||
|
||||
### 2. `viewingDays_with_times`
|
||||
Extended query that includes date ranges (`from` and `to`) along with city information.
|
||||
|
||||
### 3. `full_auction`
|
||||
Comprehensive query that fetches:
|
||||
- Auction ID and display ID
|
||||
- Bidding status
|
||||
- Buyer's premium
|
||||
- Viewing days with location and timing
|
||||
- Collection days with location and timing
|
||||
|
||||
## Configuration
|
||||
|
||||
Modify these variables in the script as needed:
|
||||
|
||||
```python
|
||||
GRAPHQL_ENDPOINT = "https://storefront.tbauctions.com/storefront/graphql"
|
||||
auction_id = "9d5d9d6b-94de-4147-b523-dfa512d85dfa" # Replace with your auction ID
|
||||
variables = {
|
||||
"auctionId": auction_id,
|
||||
"locale": "nl", # Change locale as needed
|
||||
"platform": "TWK" # Change platform as needed
|
||||
}
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
The script outputs:
|
||||
- Query name and separator
|
||||
- Success status with formatted JSON response
|
||||
- Or error messages if the query fails
|
||||
|
||||
Example output:
|
||||
```
|
||||
============================================================
|
||||
QUERY: viewingDays_simple
|
||||
============================================================
|
||||
SUCCESS:
|
||||
{
|
||||
"data": {
|
||||
"auction": {
|
||||
"viewingDays": [
|
||||
{
|
||||
"city": "Amsterdam",
|
||||
"countryCode": "NL"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
To add new queries, extend the `QUERIES` dictionary:
|
||||
|
||||
```python
|
||||
QUERIES = {
|
||||
"your_query_name": """
|
||||
query YourQuery($auctionId: TbaUuid!, $locale: String!, $platform: Platform!) {
|
||||
auction(id: $auctionId, locale: $locale, platform: $platform) {
|
||||
# Your fields here
|
||||
}
|
||||
}
|
||||
""",
|
||||
# ... existing queries
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The script includes a 500ms delay between queries to avoid rate limiting
|
||||
- Timeout is set to 30 seconds per request
|
||||
- All queries use the same GraphQL endpoint and variables
|
||||
- Error responses are displayed in a readable format
|
||||
|
||||
## License
|
||||
|
||||
This script is provided for educational and exploratory purposes.
|
||||
393
docs/INTEGRATION_FLOWCHART.md
Normal file
393
docs/INTEGRATION_FLOWCHART.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Auctiora Intelligence Integration Flowchart
|
||||
|
||||
## Complete System Integration Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ COMPLETE SYSTEM INTEGRATION DIAGRAM │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 1: EXTERNAL SCRAPER (Python/Playwright) - ARCHITECTURE-TROOSTWIJK │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────────┼─────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
[Listing Pages] [Auction Pages] [Lot Pages]
|
||||
/auctions?page=N /a/auction-id /l/lot-id
|
||||
│ │ │
|
||||
│ Extract URLs │ Parse __NEXT_DATA__ │ Parse __NEXT_DATA__
|
||||
├────────────────────────────▶│ JSON (GraphQL) │ JSON (GraphQL)
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ┌────────────────┐ ┌────────────────┐
|
||||
│ │ INSERT auctions│ │ INSERT lots │
|
||||
│ │ to SQLite │ │ INSERT images │
|
||||
│ └────────────────┘ │ (URLs only) │
|
||||
│ │ └────────────────┘
|
||||
│ │ │
|
||||
└─────────────────────────────┴────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ SQLITE DATABASE │
|
||||
│ output/cache.db │
|
||||
└──────────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
[auctions table] [lots table] [images table]
|
||||
- auction_id - lot_id - id
|
||||
- title - auction_id - lot_id
|
||||
- location - title - url
|
||||
- lots_count - current_bid - local_path
|
||||
- closing_time - bid_count - downloaded=0
|
||||
- closing_time
|
||||
- followersCount ⭐ NEW
|
||||
- estimatedMin ⭐ NEW
|
||||
- estimatedMax ⭐ NEW
|
||||
- nextBidStepInCents ⭐ NEW
|
||||
- condition ⭐ NEW
|
||||
- vat ⭐ NEW
|
||||
- buyerPremiumPercentage ⭐ NEW
|
||||
- quantity ⭐ NEW
|
||||
- biddingStatus ⭐ NEW
|
||||
- remarks ⭐ NEW
|
||||
│
|
||||
┌─────────────────────────────────────┴─────────────────────────────────────┐
|
||||
│ PHASE 2: MONITORING & PROCESSING (Java) - THIS PROJECT │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
[TroostwijkMonitor] [DatabaseService] [ScraperDataAdapter]
|
||||
│ │ │
|
||||
│ Read lots │ Query lots │ Transform data
|
||||
│ every hour │ Import images │ TEXT → INTEGER
|
||||
│ │ │ "€123" → 123.0
|
||||
└─────────────────┴─────────────────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
[Bid Monitoring] [Image Processing] [Closing Alerts]
|
||||
Check API every 1h Download images Check < 5 min
|
||||
│ │ │
|
||||
│ New bid? │ Process via │ Time critical?
|
||||
├─[YES]──────────┐ │ ObjectDetection ├─[YES]────┐
|
||||
│ │ │ │ │
|
||||
▼ │ ▼ │ │
|
||||
[Update current_bid] │ ┌──────────────────┐ │ │
|
||||
in database │ │ YOLO Detection │ │ │
|
||||
│ │ OpenCV DNN │ │ │
|
||||
│ └──────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Detect objects │ │
|
||||
│ ├─[vehicle] │ │
|
||||
│ ├─[furniture] │ │
|
||||
│ ├─[machinery] │ │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ [Save labels to DB] │ │
|
||||
│ [Estimate value] │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
└─────────┴───────────────────────┴──────────┘
|
||||
│
|
||||
┌───────────────────────────────────────────────┴────────────────────────────┐
|
||||
│ PHASE 3: INTELLIGENCE LAYER ⭐ NEW - PREDICTIVE ANALYTICS │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┴─────────────────┐
|
||||
▼ ▼
|
||||
[Intelligence Engine] [Analytics Calculations]
|
||||
│ │
|
||||
┌───────────────────┼──────────────┐ │
|
||||
▼ ▼ ▼ │
|
||||
[Sleeper Detection] [Bargain Finder] [Popularity Tracker] │
|
||||
High followers Price < estimate Watch count analysis │
|
||||
Low current bid Opportunity Competition level │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
└───────────────────┴──────────────┴───────────────────┘
|
||||
│
|
||||
┌─────────────────┴─────────────────┐
|
||||
▼ ▼
|
||||
[Total Cost Calculator] [Next Bid Calculator]
|
||||
Current bid × (1 + VAT/100) Current bid + increment
|
||||
× (1 + premium/100) (from API or calculated)
|
||||
│ │
|
||||
└─────────────────┬─────────────────┘
|
||||
│
|
||||
┌───────────────────────────────────────────────┴────────────────────────────┐
|
||||
│ PHASE 4: NOTIFICATION SYSTEM - USER INTERACTION TRIGGERS │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┴─────────────────┐
|
||||
▼ ▼
|
||||
[NotificationService] [User Decision Points]
|
||||
│ │
|
||||
┌───────────────────┼───────────────────┐ │
|
||||
▼ ▼ ▼ │
|
||||
[Desktop Notify] [Email Notify] [Priority Level] │
|
||||
Windows/macOS/ Gmail SMTP 0=Normal │
|
||||
Linux system (FREE) 1=High │
|
||||
tray │
|
||||
│ │ │ │
|
||||
└───────────────────┴───────────────────┘ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ USER INTERACTION │ │ TRIGGER EVENTS: │
|
||||
│ NOTIFICATIONS │ │ │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
│ │
|
||||
┌───────────────────┼───────────────────┐ │
|
||||
▼ ▼ ▼ │
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ 1. BID CHANGE │ │ 2. OBJECT │ │ 3. CLOSING │ │
|
||||
│ │ │ DETECTED │ │ ALERT │ │
|
||||
│ "Nieuw bod op │ │ │ │ │ │
|
||||
│ kavel 12345: │ │ "Lot contains: │ │ "Kavel 12345 │ │
|
||||
│ €150 (was €125)"│ │ - Vehicle │ │ sluit binnen │ │
|
||||
│ │ │ - Machinery │ │ 5 min." │ │
|
||||
│ Priority: NORMAL │ │ Est: €5000" │ │ Priority: HIGH │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ Action needed: │ │ Action needed: │ │ Action needed: │ │
|
||||
│ ▸ Place bid? │ │ ▸ Review item? │ │ ▸ Place final │ │
|
||||
│ ▸ Monitor? │ │ ▸ Confirm value? │ │ bid? │ │
|
||||
│ ▸ Ignore? │ │ ▸ Add to watch? │ │ ▸ Let expire? │ │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │ │ │
|
||||
└───────────────────┴───────────────────┴─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ INTELLIGENCE NOTIFICATIONS ⭐ NEW │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 4. SLEEPER LOT ALERT │
|
||||
│ "Lot 12345: 25 watchers, only €50 bid - Opportunity!" │
|
||||
│ Action: ▸ Place strategic bid ▸ Monitor competition ▸ Set alert │
|
||||
│ │
|
||||
│ 5. BARGAIN DETECTED │
|
||||
│ "Lot 67890: Current €200, Estimate €400-€600 - Below estimate!" │
|
||||
│ Action: ▸ Bid now ▸ Research comparable ▸ Add to watchlist │
|
||||
│ │
|
||||
│ 6. HIGH COMPETITION WARNING │
|
||||
│ "Lot 11111: 75 watchers, bid velocity 5/hr - Strong competition" │
|
||||
│ Action: ▸ Review strategy ▸ Set max bid ▸ Find alternatives │
|
||||
│ │
|
||||
│ 7. TOTAL COST NOTIFICATION │
|
||||
│ "True cost: €500 bid + €105 VAT (21%) + €50 premium (10%) = €655" │
|
||||
│ Action: ▸ Confirm budget ▸ Adjust bid ▸ Calculate logistics │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Intelligence Dashboard Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph P1["PHASE 1: DATA COLLECTION"]
|
||||
A1[GraphQL API] --> A2[Scraper Extracts 15+ New Fields]
|
||||
A2 --> A3[followersCount]
|
||||
A2 --> A4[estimatedMin/Max]
|
||||
A2 --> A5[nextBidStepInCents]
|
||||
A2 --> A6[vat + buyerPremiumPercentage]
|
||||
A2 --> A7[condition + biddingStatus]
|
||||
|
||||
A3 & A4 & A5 & A6 & A7 --> DB[(SQLite Database)]
|
||||
end
|
||||
|
||||
DB --> P2_Entry
|
||||
|
||||
subgraph P2["PHASE 2: INTELLIGENCE PROCESSING"]
|
||||
P2_Entry[Lot.java Model] --> Intelligence[Intelligence Methods]
|
||||
|
||||
Intelligence --> Sleeper[isSleeperLot<br/>High followers, low bid]
|
||||
Intelligence --> Bargain[isBelowEstimate<br/>Price < estimate]
|
||||
Intelligence --> Popular[getPopularityLevel<br/>Watch count tiers]
|
||||
Intelligence --> Cost[calculateTotalCost<br/>Bid + VAT + Premium]
|
||||
Intelligence --> NextBid[calculateNextBid<br/>API increment]
|
||||
end
|
||||
|
||||
P2_Entry --> API_Layer
|
||||
|
||||
subgraph API["PHASE 3: REST API ENDPOINTS"]
|
||||
API_Layer[AuctionMonitorResource] --> E1[/intelligence/sleepers]
|
||||
API_Layer --> E2[/intelligence/bargains]
|
||||
API_Layer --> E3[/intelligence/popular]
|
||||
API_Layer --> E4[/intelligence/price-analysis]
|
||||
API_Layer --> E5[/lots/:id/intelligence]
|
||||
API_Layer --> E6[/charts/watch-distribution]
|
||||
end
|
||||
|
||||
E1 & E2 & E3 & E4 & E5 & E6 --> Dashboard
|
||||
|
||||
subgraph UI["PHASE 4: INTELLIGENCE DASHBOARD"]
|
||||
Dashboard[index.html] --> Widget1[Sleeper Lots Widget<br/>Opportunities]
|
||||
Dashboard --> Widget2[Bargain Lots Widget<br/>Below Estimate]
|
||||
Dashboard --> Widget3[Popular Lots Widget<br/>High Competition]
|
||||
Dashboard --> Table[Enhanced Table<br/>Watchers | Est. Range | Total Cost]
|
||||
|
||||
Table --> Badges[Smart Badges:<br/>DEAL | Watch Count | Time Left]
|
||||
end
|
||||
|
||||
Widget1 --> UserAction
|
||||
Widget2 --> UserAction
|
||||
Widget3 --> UserAction
|
||||
Table --> UserAction
|
||||
|
||||
subgraph Actions["PHASE 5: USER ACTIONS"]
|
||||
UserAction[User Decision] --> Bid[Place Strategic Bid]
|
||||
UserAction --> Monitor[Add to Watchlist]
|
||||
UserAction --> Research[Research Comparables]
|
||||
UserAction --> Calculate[Budget Calculator]
|
||||
end
|
||||
```
|
||||
|
||||
## Key Intelligence Features
|
||||
|
||||
### 1. Follower/Watch Count Analytics
|
||||
- **Data Source**: `followersCount` from GraphQL API
|
||||
- **Intelligence Value**:
|
||||
- Predict lot popularity before bidding wars
|
||||
- Calculate interest-to-bid conversion rates
|
||||
- Identify "sleeper" lots (high followers, low bids)
|
||||
- Alert on sudden interest spikes
|
||||
|
||||
### 2. Price vs Estimate Analysis
|
||||
- **Data Source**: `estimatedMin`, `estimatedMax` from GraphQL API
|
||||
- **Intelligence Value**:
|
||||
- Identify bargains: `currentBid < estimatedMin`
|
||||
- Identify overvalued: `currentBid > estimatedMax`
|
||||
- Build pricing models per category
|
||||
- Track auction house estimate accuracy
|
||||
|
||||
### 3. True Cost Calculator
|
||||
- **Data Source**: `vat`, `buyerPremiumPercentage` from GraphQL API
|
||||
- **Intelligence Value**:
|
||||
- Calculate total cost: `bid × (1 + VAT/100) × (1 + premium/100)`
|
||||
- Budget planning with accurate all-in costs
|
||||
- Compare true costs across lots
|
||||
- Prevent bidding surprises
|
||||
|
||||
### 4. Exact Bid Increment
|
||||
- **Data Source**: `nextBidStepInCents` from GraphQL API
|
||||
- **Intelligence Value**:
|
||||
- Show exact next bid amount
|
||||
- No calculation errors
|
||||
- Better UX for bidding recommendations
|
||||
- Strategic bid placement
|
||||
|
||||
### 5. Structured Location & Category
|
||||
- **Data Source**: `cityLocation`, `countryCode`, `categoryPath` from GraphQL API
|
||||
- **Intelligence Value**:
|
||||
- Filter by distance from user
|
||||
- Calculate pickup logistics costs
|
||||
- Category-based analytics
|
||||
- Regional pricing trends
|
||||
|
||||
## Integration Hooks & Timing
|
||||
|
||||
| Event | Frequency | Trigger | Notification Type | User Action Required |
|
||||
|--------------------------------|-------------------|----------------------------|----------------------------|------------------------|
|
||||
| **Sleeper lot detected** | On data refresh | followers > 10, bid < €100 | Desktop + Email | Review opportunity |
|
||||
| **Bargain detected** | On data refresh | bid < estimatedMin | Desktop + Email | Consider bidding |
|
||||
| **High competition** | On data refresh | followers > 50 | Desktop | Review strategy |
|
||||
| **Bid change detected** | Every 1 hour | Monitor detects higher bid | Desktop + Email | Place counter-bid? |
|
||||
| **Closing soon (< 30 min)** | When detected | Time-based check | Desktop + Email | Review lot |
|
||||
| **Closing imminent (< 5 min)** | When detected | Time-based check | Desktop + Email (HIGH) | Final bid decision |
|
||||
| **Object detected** | On image process | YOLO finds objects | Desktop + Email | Confirm identification |
|
||||
| **True cost calculated** | On page load | User views lot | Dashboard display | Budget confirmation |
|
||||
|
||||
## API Endpoints Reference
|
||||
|
||||
### Intelligence Endpoints
|
||||
- `GET /api/monitor/intelligence/sleepers` - Returns high-interest, low-bid lots
|
||||
- `GET /api/monitor/intelligence/bargains` - Returns lots priced below estimate
|
||||
- `GET /api/monitor/intelligence/popular?level={HIGH|MEDIUM|LOW}` - Returns lots by popularity
|
||||
- `GET /api/monitor/intelligence/price-analysis` - Returns price vs estimate statistics
|
||||
- `GET /api/monitor/lots/{lotId}/intelligence` - Returns detailed intelligence for specific lot
|
||||
|
||||
### Chart Endpoints
|
||||
- `GET /api/monitor/charts/watch-distribution` - Returns follower count distribution
|
||||
- `GET /api/monitor/charts/country-distribution` - Returns geographic distribution
|
||||
- `GET /api/monitor/charts/category-distribution` - Returns category distribution
|
||||
- `GET /api/monitor/charts/bidding-trend?hours=24` - Returns time series data
|
||||
|
||||
## Dashboard Intelligence Widgets
|
||||
|
||||
### Sleeper Lots Widget
|
||||
- **Color**: Purple gradient
|
||||
- **Icon**: Eye (fa-eye)
|
||||
- **Metric**: Count of lots with followers > 10 and bid < €100
|
||||
- **Action**: Click to filter table to sleeper lots only
|
||||
|
||||
### Bargain Lots Widget
|
||||
- **Color**: Green gradient
|
||||
- **Icon**: Tag (fa-tag)
|
||||
- **Metric**: Count of lots where current bid < estimated minimum
|
||||
- **Action**: Click to filter table to bargain lots only
|
||||
|
||||
### Popular/Hot Lots Widget
|
||||
- **Color**: Orange gradient
|
||||
- **Icon**: Fire (fa-fire)
|
||||
- **Metric**: Count of lots with followers > 20
|
||||
- **Action**: Click to filter table to popular lots only
|
||||
|
||||
## Enhanced Table Features
|
||||
|
||||
### New Columns
|
||||
1. **Watchers** - Shows follower count with color-coded badges:
|
||||
- 50+ followers: Red (high competition)
|
||||
- 21-50 followers: Orange (medium competition)
|
||||
- 6-20 followers: Blue (some interest)
|
||||
- 0-5 followers: Gray (minimal interest)
|
||||
|
||||
2. **Est. Range** - Shows auction house estimate: `€min-€max`
|
||||
- Displays "DEAL" badge if current bid < estimate
|
||||
|
||||
3. **Total Cost** - Shows true cost including VAT and buyer premium:
|
||||
- Hover tooltip shows breakdown: `Including VAT (21%) + Premium (10%)`
|
||||
|
||||
### Smart Indicators
|
||||
- **DEAL Badge**: Green badge when `currentBid < estimatedMin`
|
||||
- **Watch Count Badge**: Color-coded by competition level
|
||||
- **Urgency Badge**: Time-based coloring (< 10 min = red)
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Backend (Java)
|
||||
- **File**: `src/main/java/auctiora/Lot.java`
|
||||
- Added 24 new fields from GraphQL API
|
||||
- Added 9 intelligence calculation methods
|
||||
- Immutable record with Lombok `@With` annotation
|
||||
|
||||
- **File**: `src/main/java/auctiora/AuctionMonitorResource.java`
|
||||
- Added 6 new REST API endpoints
|
||||
- Enhanced insights with sleeper/bargain/popular detection
|
||||
- Added watch distribution chart endpoint
|
||||
|
||||
### Frontend (HTML/JavaScript)
|
||||
- **File**: `src/main/resources/META-INF/resources/index.html`
|
||||
- Added 3 intelligence widgets with click handlers
|
||||
- Enhanced closing soon table with 3 new columns
|
||||
- Added `fetchIntelligenceData()` function
|
||||
- Added smart badges and color coding
|
||||
- Added total cost calculator display
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Bid History Table** - Track bid changes over time
|
||||
2. **Comparative Analytics** - Compare similar lots across auctions
|
||||
3. **Machine Learning** - Predict final hammer price based on patterns
|
||||
4. **Geographic Filtering** - Distance-based sorting and filtering
|
||||
5. **Email Alerts** - Custom alerts for sleepers, bargains, etc.
|
||||
6. **Mobile App** - Push notifications for time-critical events
|
||||
7. **Bid Automation** - Auto-bid up to maximum with increment logic
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 2025
|
||||
**Version**: 2.1
|
||||
**Author**: Auctiora Intelligence Team
|
||||
422
docs/INTELLIGENCE_FEATURES_SUMMARY.md
Normal file
422
docs/INTELLIGENCE_FEATURES_SUMMARY.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# Intelligence Features Implementation Summary
|
||||
|
||||
## Overview
|
||||
This document summarizes the implementation of advanced intelligence features based on 15+ new GraphQL API fields discovered from the Troostwijk auction system.
|
||||
|
||||
## New GraphQL Fields Integrated
|
||||
|
||||
### HIGH PRIORITY FIELDS (Implemented)
|
||||
1. **`followersCount`** (Integer) - Watch count showing bidder interest
|
||||
- Direct indicator of competition
|
||||
- Used for sleeper lot detection
|
||||
- Popularity level classification
|
||||
|
||||
2. **`estimatedFullPrice`** (Object: min/max cents)
|
||||
- Auction house's estimated value range
|
||||
- Used for bargain detection
|
||||
- Price vs estimate analytics
|
||||
|
||||
3. **`nextBidStepInCents`** (Long)
|
||||
- Exact bid increment from API
|
||||
- Precise next bid calculations
|
||||
- Better UX for bidding recommendations
|
||||
|
||||
4. **`condition`** (String)
|
||||
- Direct condition field from API
|
||||
- Better than extracting from attributes
|
||||
- Used in condition scoring
|
||||
|
||||
5. **`categoryInformation`** (Object)
|
||||
- Structured category with path
|
||||
- Better categorization and filtering
|
||||
- Category-based analytics
|
||||
|
||||
6. **`location`** (Object: city, countryCode, etc.)
|
||||
- Structured location data
|
||||
- Proximity filtering capability
|
||||
- Logistics cost calculation
|
||||
|
||||
### MEDIUM PRIORITY FIELDS (Implemented)
|
||||
7. **`biddingStatus`** (Enum) - Detailed bidding status
|
||||
8. **`appearance`** (String) - Visual condition notes
|
||||
9. **`packaging`** (String) - Packaging details
|
||||
10. **`quantity`** (Long) - Lot quantity for bulk items
|
||||
11. **`vat`** (BigDecimal) - VAT percentage
|
||||
12. **`buyerPremiumPercentage`** (BigDecimal) - Buyer premium
|
||||
13. **`remarks`** (String) - Viewing/pickup notes
|
||||
|
||||
## Code Changes
|
||||
|
||||
### 1. Backend - Lot.java (Domain Model)
|
||||
**File**: `src/main/java/auctiora/Lot.java`
|
||||
|
||||
**Changes**:
|
||||
- Added 24 new fields to the Lot record
|
||||
- Implemented 9 intelligence calculation methods:
|
||||
- `calculateTotalCost()` - Bid + VAT + Premium
|
||||
- `calculateNextBid()` - Using API increment
|
||||
- `isBelowEstimate()` - Bargain detection
|
||||
- `isAboveEstimate()` - Overvalued detection
|
||||
- `getInterestToBidRatio()` - Conversion rate
|
||||
- `getPopularityLevel()` - HIGH/MEDIUM/LOW/MINIMAL
|
||||
- `isSleeperLot()` - High interest, low bid
|
||||
- `getEstimatedMidpoint()` - Average of estimate range
|
||||
- `getPriceVsEstimateRatio()` - Price comparison metric
|
||||
|
||||
**Example**:
|
||||
```java
|
||||
public boolean isSleeperLot() {
|
||||
return followersCount != null && followersCount > 10 && currentBid < 100;
|
||||
}
|
||||
|
||||
public double calculateTotalCost() {
|
||||
double base = currentBid > 0 ? currentBid : 0;
|
||||
if (vat != null && vat > 0) {
|
||||
base += (base * vat / 100.0);
|
||||
}
|
||||
if (buyerPremiumPercentage != null && buyerPremiumPercentage > 0) {
|
||||
base += (base * buyerPremiumPercentage / 100.0);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Backend - AuctionMonitorResource.java (REST API)
|
||||
**File**: `src/main/java/auctiora/AuctionMonitorResource.java`
|
||||
|
||||
**New Endpoints Added**:
|
||||
1. `GET /api/monitor/intelligence/sleepers` - Sleeper lots (high interest, low bids)
|
||||
2. `GET /api/monitor/intelligence/bargains` - Bargain lots (below estimate)
|
||||
3. `GET /api/monitor/intelligence/popular?level={HIGH|MEDIUM|LOW}` - Popular lots
|
||||
4. `GET /api/monitor/intelligence/price-analysis` - Price vs estimate statistics
|
||||
5. `GET /api/monitor/lots/{lotId}/intelligence` - Detailed lot intelligence
|
||||
6. `GET /api/monitor/charts/watch-distribution` - Follower count distribution
|
||||
|
||||
**Enhanced Features**:
|
||||
- Updated insights endpoint to include sleeper, bargain, and popular insights
|
||||
- Added intelligent filtering and sorting for intelligence data
|
||||
- Integrated new fields into existing statistics
|
||||
|
||||
**Example Endpoint**:
|
||||
```java
|
||||
@GET
|
||||
@Path("/intelligence/sleepers")
|
||||
public Response getSleeperLots(@QueryParam("minFollowers") @DefaultValue("10") int minFollowers) {
|
||||
var allLots = db.getAllLots();
|
||||
var sleepers = allLots.stream()
|
||||
.filter(Lot::isSleeperLot)
|
||||
.toList();
|
||||
|
||||
return Response.ok(Map.of(
|
||||
"count", sleepers.size(),
|
||||
"lots", sleepers
|
||||
)).build();
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Frontend - index.html (Intelligence Dashboard)
|
||||
**File**: `src/main/resources/META-INF/resources/index.html`
|
||||
|
||||
**New UI Components**:
|
||||
|
||||
#### Intelligence Dashboard Widgets (3 new cards)
|
||||
1. **Sleeper Lots Widget**
|
||||
- Purple gradient design
|
||||
- Shows count of high-interest, low-bid lots
|
||||
- Click to filter table
|
||||
|
||||
2. **Bargain Lots Widget**
|
||||
- Green gradient design
|
||||
- Shows count of below-estimate lots
|
||||
- Click to filter table
|
||||
|
||||
3. **Popular/Hot Lots Widget**
|
||||
- Orange gradient design
|
||||
- Shows count of high-follower lots
|
||||
- Click to filter table
|
||||
|
||||
#### Enhanced Closing Soon Table
|
||||
**New Columns Added**:
|
||||
1. **Watchers** - Follower count with color-coded badges
|
||||
- Red (50+ followers): High competition
|
||||
- Orange (21-50): Medium competition
|
||||
- Blue (6-20): Some interest
|
||||
- Gray (0-5): Minimal interest
|
||||
|
||||
2. **Est. Range** - Auction house estimate (`€min-€max`)
|
||||
- Shows "DEAL" badge if below estimate
|
||||
|
||||
3. **Total Cost** - True cost including VAT and premium
|
||||
- Hover tooltip shows breakdown
|
||||
- Purple color to stand out
|
||||
|
||||
**JavaScript Functions Added**:
|
||||
- `fetchIntelligenceData()` - Fetches all intelligence metrics
|
||||
- `showSleeperLots()` - Filters table to sleepers
|
||||
- `showBargainLots()` - Filters table to bargains
|
||||
- `showPopularLots()` - Filters table to popular
|
||||
- Enhanced table rendering with smart badges
|
||||
|
||||
**Example Code**:
|
||||
```javascript
|
||||
// Calculate total cost (including VAT and premium)
|
||||
const currentBid = lot.currentBid || 0;
|
||||
const vat = lot.vat || 0;
|
||||
const premium = lot.buyerPremiumPercentage || 0;
|
||||
const totalCost = currentBid * (1 + (vat/100) + (premium/100));
|
||||
|
||||
// Bargain indicator
|
||||
const isBargain = estMin && currentBid < parseFloat(estMin);
|
||||
const bargainBadge = isBargain ?
|
||||
'<span class="ml-1 text-xs bg-green-500 text-white px-1 rounded">DEAL</span>' : '';
|
||||
```
|
||||
|
||||
## Intelligence Features
|
||||
|
||||
### 1. Sleeper Lot Detection
|
||||
**Algorithm**: `followersCount > 10 AND currentBid < 100`
|
||||
|
||||
**Value Proposition**:
|
||||
- Identifies lots with high interest but low current bids
|
||||
- Opportunity to bid strategically before price escalates
|
||||
- Early indicator of undervalued items
|
||||
|
||||
**Dashboard Display**:
|
||||
- Count shown in purple widget
|
||||
- Click to filter table
|
||||
- Purple "eye" icon
|
||||
|
||||
### 2. Bargain Detection
|
||||
**Algorithm**: `currentBid < estimatedMin`
|
||||
|
||||
**Value Proposition**:
|
||||
- Identifies lots priced below auction house estimate
|
||||
- Clear signal of potential good deals
|
||||
- Quantifiable value assessment
|
||||
|
||||
**Dashboard Display**:
|
||||
- Count shown in green widget
|
||||
- "DEAL" badge in table
|
||||
- Click to filter table
|
||||
|
||||
### 3. Popularity Analysis
|
||||
**Algorithm**: Tiered classification by follower count
|
||||
- HIGH: > 50 followers
|
||||
- MEDIUM: 21-50 followers
|
||||
- LOW: 6-20 followers
|
||||
- MINIMAL: 0-5 followers
|
||||
|
||||
**Value Proposition**:
|
||||
- Predict competition level
|
||||
- Identify trending items
|
||||
- Adjust bidding strategy accordingly
|
||||
|
||||
**Dashboard Display**:
|
||||
- Count shown in orange widget
|
||||
- Color-coded badges in table
|
||||
- Click to filter by level
|
||||
|
||||
### 4. True Cost Calculator
|
||||
**Algorithm**: `currentBid × (1 + VAT/100) × (1 + premium/100)`
|
||||
|
||||
**Value Proposition**:
|
||||
- Shows actual out-of-pocket cost
|
||||
- Prevents budget surprises
|
||||
- Enables accurate comparison across lots
|
||||
|
||||
**Dashboard Display**:
|
||||
- Purple "Total Cost" column
|
||||
- Hover tooltip shows breakdown
|
||||
- Updated in real-time
|
||||
|
||||
### 5. Exact Bid Increment
|
||||
**Algorithm**: Uses `nextBidStepInCents` from API, falls back to calculated increment
|
||||
|
||||
**Value Proposition**:
|
||||
- No guesswork on next bid amount
|
||||
- API-provided accuracy
|
||||
- Better bidding UX
|
||||
|
||||
**Implementation**:
|
||||
```java
|
||||
public double calculateNextBid() {
|
||||
if (nextBidStepInCents != null && nextBidStepInCents > 0) {
|
||||
return currentBid + (nextBidStepInCents / 100.0);
|
||||
} else if (bidIncrement != null && bidIncrement > 0) {
|
||||
return currentBid + bidIncrement;
|
||||
}
|
||||
return currentBid * 1.05; // Fallback: 5% increment
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Price vs Estimate Analytics
|
||||
**Metrics**:
|
||||
- Total lots with estimates
|
||||
- Count below estimate
|
||||
- Count above estimate
|
||||
- Average price vs estimate percentage
|
||||
|
||||
**Value Proposition**:
|
||||
- Market efficiency analysis
|
||||
- Auction house accuracy tracking
|
||||
- Investment opportunity identification
|
||||
|
||||
**API Endpoint**: `/api/monitor/intelligence/price-analysis`
|
||||
|
||||
## Visual Design
|
||||
|
||||
### Color Scheme
|
||||
- **Purple**: Sleeper lots, total cost (opportunity/value)
|
||||
- **Green**: Bargains, deals (positive value)
|
||||
- **Orange/Red**: Popular/hot lots (competition warning)
|
||||
- **Blue**: Moderate interest (informational)
|
||||
- **Gray**: Minimal interest (neutral)
|
||||
|
||||
### Badge System
|
||||
1. **Watchers Badge**: Color-coded by competition level
|
||||
2. **DEAL Badge**: Green indicator for below-estimate
|
||||
3. **Time Left Badge**: Red/yellow/green by urgency
|
||||
4. **Popularity Badge**: Fire icon for hot lots
|
||||
|
||||
### Interactive Elements
|
||||
- Click widgets to filter table
|
||||
- Hover for detailed tooltips
|
||||
- Smooth scroll to table on filter
|
||||
- Toast notifications for user feedback
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### API Optimization
|
||||
- All intelligence data fetched in parallel
|
||||
- Cached in dashboard state
|
||||
- Minimal recalculation on render
|
||||
- Efficient stream operations in backend
|
||||
|
||||
### Frontend Optimization
|
||||
- Batch DOM updates
|
||||
- Lazy rendering for large tables
|
||||
- Debounced filter operations
|
||||
- CSS transitions for smooth UX
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### Backend Tests
|
||||
1. Test `Lot` intelligence methods with various inputs
|
||||
2. Test API endpoints with mock data
|
||||
3. Test edge cases (null values, zero bids, etc.)
|
||||
4. Performance test with 10k+ lots
|
||||
|
||||
### Frontend Tests
|
||||
1. Test widget click handlers
|
||||
2. Test table rendering with new columns
|
||||
3. Test filter functionality
|
||||
4. Test responsive design on mobile
|
||||
|
||||
### Integration Tests
|
||||
1. End-to-end flow: Scraper → DB → API → Dashboard
|
||||
2. Real-time data refresh
|
||||
3. Concurrent user access
|
||||
4. Load testing
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2 (Bid History)
|
||||
- Implement `bid_history` table scraping
|
||||
- Track bid changes over time
|
||||
- Calculate bid velocity accurately
|
||||
- Identify bid patterns
|
||||
|
||||
### Phase 3 (ML Predictions)
|
||||
- Predict final hammer price
|
||||
- Recommend optimal bid timing
|
||||
- Classify lot categories automatically
|
||||
- Anomaly detection
|
||||
|
||||
### Phase 4 (Mobile)
|
||||
- React Native mobile app
|
||||
- Push notifications
|
||||
- Offline mode
|
||||
- Quick bid functionality
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Database Migration (Required)
|
||||
The new fields need to be added to the database schema:
|
||||
|
||||
```sql
|
||||
-- Add to lots table
|
||||
ALTER TABLE lots ADD COLUMN followers_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE lots ADD COLUMN estimated_min DECIMAL(12, 2);
|
||||
ALTER TABLE lots ADD COLUMN estimated_max DECIMAL(12, 2);
|
||||
ALTER TABLE lots ADD COLUMN next_bid_step_in_cents BIGINT;
|
||||
ALTER TABLE lots ADD COLUMN condition TEXT;
|
||||
ALTER TABLE lots ADD COLUMN category_path TEXT;
|
||||
ALTER TABLE lots ADD COLUMN city_location TEXT;
|
||||
ALTER TABLE lots ADD COLUMN country_code TEXT;
|
||||
ALTER TABLE lots ADD COLUMN bidding_status TEXT;
|
||||
ALTER TABLE lots ADD COLUMN appearance TEXT;
|
||||
ALTER TABLE lots ADD COLUMN packaging TEXT;
|
||||
ALTER TABLE lots ADD COLUMN quantity BIGINT;
|
||||
ALTER TABLE lots ADD COLUMN vat DECIMAL(5, 2);
|
||||
ALTER TABLE lots ADD COLUMN buyer_premium_percentage DECIMAL(5, 2);
|
||||
ALTER TABLE lots ADD COLUMN remarks TEXT;
|
||||
ALTER TABLE lots ADD COLUMN starting_bid DECIMAL(12, 2);
|
||||
ALTER TABLE lots ADD COLUMN reserve_price DECIMAL(12, 2);
|
||||
ALTER TABLE lots ADD COLUMN reserve_met BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE lots ADD COLUMN bid_increment DECIMAL(12, 2);
|
||||
ALTER TABLE lots ADD COLUMN view_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE lots ADD COLUMN first_bid_time TEXT;
|
||||
ALTER TABLE lots ADD COLUMN last_bid_time TEXT;
|
||||
ALTER TABLE lots ADD COLUMN bid_velocity DECIMAL(5, 2);
|
||||
```
|
||||
|
||||
### Scraper Update (Required)
|
||||
The external scraper (Python/Playwright) needs to extract the new fields from GraphQL:
|
||||
|
||||
```python
|
||||
# Extract from __NEXT_DATA__ JSON
|
||||
followers_count = lot_data.get('followersCount')
|
||||
estimated_min = lot_data.get('estimatedFullPrice', {}).get('min', {}).get('cents')
|
||||
estimated_max = lot_data.get('estimatedFullPrice', {}).get('max', {}).get('cents')
|
||||
next_bid_step = lot_data.get('nextBidStepInCents')
|
||||
condition = lot_data.get('condition')
|
||||
# ... etc
|
||||
```
|
||||
|
||||
### Deployment Steps
|
||||
1. Stop the monitor service
|
||||
2. Run database migrations
|
||||
3. Update scraper to extract new fields
|
||||
4. Deploy updated monitor JAR
|
||||
5. Restart services
|
||||
6. Verify data populating in dashboard
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Expected Performance
|
||||
- **Intelligence Data Fetch**: < 100ms for 10k lots
|
||||
- **Table Rendering**: < 200ms with all new columns
|
||||
- **Widget Update**: < 50ms
|
||||
- **API Response Time**: < 500ms
|
||||
|
||||
### Resource Usage
|
||||
- **Memory**: +50MB for intelligence calculations
|
||||
- **Database**: +2KB per lot (new columns)
|
||||
- **Network**: +10KB per dashboard refresh
|
||||
|
||||
## Documentation
|
||||
- **Integration Flowchart**: `docs/INTEGRATION_FLOWCHART.md`
|
||||
- **API Documentation**: Auto-generated from JAX-RS annotations
|
||||
- **Database Schema**: `wiki/DATABASE_ARCHITECTURE.md`
|
||||
- **GraphQL Fields**: `wiki/EXPERT_ANALITICS.sql`
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: December 2025
|
||||
**Version**: 2.1
|
||||
**Status**: ✅ Complete - Ready for Testing
|
||||
**Next Steps**:
|
||||
1. Deploy to staging environment
|
||||
2. Run integration tests
|
||||
3. Update scraper to extract new fields
|
||||
4. Deploy to production
|
||||
@@ -208,7 +208,7 @@ ENTRYPOINT ["java", "-jar", "/app/quarkus-run.jar"]
|
||||
version: '3.8'
|
||||
services:
|
||||
auction-monitor:
|
||||
build: .
|
||||
build: ../wiki
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
@@ -218,7 +218,7 @@ services:
|
||||
- AUCTION_DATABASE_PATH=/mnt/okcomputer/output/cache.db
|
||||
- AUCTION_NOTIFICATION_CONFIG=desktop
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "http://localhost:8081/health/live"]
|
||||
test: [ "CMD", "wget", "--spider", "http://localhost:8081/health/live" ]
|
||||
interval: 30s
|
||||
restart: unless-stopped
|
||||
```
|
||||
@@ -361,14 +361,14 @@ WHERE id NOT IN (
|
||||
|
||||
## 📈 Performance Comparison
|
||||
|
||||
| Metric | Before (Monitor Downloads) | After (Scraper Downloads) |
|
||||
|--------|---------------------------|---------------------------|
|
||||
| **Image records** | 57,376,293 | ~16,807 |
|
||||
| **Duplicates** | 57,359,486 (99.97%!) | 0 |
|
||||
| **Network I/O** | Monitor process | Scraper process |
|
||||
| **Disk usage** | 0 (URLs only) | ~1.6GB (actual files) |
|
||||
| Metric | Before (Monitor Downloads) | After (Scraper Downloads) |
|
||||
|----------------------|---------------------------------|---------------------------|
|
||||
| **Image records** | 57,376,293 | ~16,807 |
|
||||
| **Duplicates** | 57,359,486 (99.97%!) | 0 |
|
||||
| **Network I/O** | Monitor process | Scraper process |
|
||||
| **Disk usage** | 0 (URLs only) | ~1.6GB (actual files) |
|
||||
| **Processing speed** | 500ms/image (download + detect) | 100ms/image (detect only) |
|
||||
| **Error handling** | Complex (download failures) | Simple (files exist) |
|
||||
| **Error handling** | Complex (download failures) | Simple (files exist) |
|
||||
|
||||
## 🎓 Code Examples by Language
|
||||
|
||||
304
docs/VALUATION.md
Normal file
304
docs/VALUATION.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Auction Valuation Mathematics - Technical Reference
|
||||
|
||||
## 1. Fair Market Value (FMV) - Core Valuation Formula
|
||||
|
||||
The baseline valuation is calculated using a **weighted comparable sales approach**:
|
||||
|
||||
$$
|
||||
FMV = \frac{\sum_{i=1}^{n} \left( P_i \cdot \omega_c \cdot \omega_t \cdot \omega_p \cdot \omega_h \right)}{\sum_{i=1}^{n} \left( \omega_c \cdot \omega_t \cdot \omega_p \cdot \omega_h \right)}
|
||||
$$
|
||||
|
||||
**Variables:**
|
||||
- $P_i$ = Final hammer price of comparable lot *i* (€)
|
||||
- $\omega_c$ = **Condition weight**: $\exp(-\lambda_c \cdot |C_{target} - C_i|)$
|
||||
- $\omega_t$ = **Time weight**: $\exp(-\lambda_t \cdot |T_{target} - T_i|)$
|
||||
- $\omega_p$ = **Provenance weight**: $1 + \delta_p \cdot (P_{target} - P_i)$
|
||||
- $\omega_h$ = **Historical weight**: $\left( \frac{1}{1 + e^{-kh \cdot (D_i - D_{median})}} \right)$
|
||||
|
||||
**Parameter Definitions:**
|
||||
- $C \in [0, 10]$ = Condition score (10 = perfect)
|
||||
- $T$ = Manufacturing year
|
||||
- $P \in \{0,1\}$ = Provenance flag (1 = documented history)
|
||||
- $D_i$ = Days since comparable sale
|
||||
- $\lambda_c = 0.693$ = Condition decay constant (50% weight at 1-point difference)
|
||||
- $\lambda_t = 0.048$ = Time decay constant (50% weight at 15-year difference)
|
||||
- $\delta_p = 0.15$ = Provenance premium coefficient
|
||||
- $kh = 0.01$ = Historical relevance coefficient
|
||||
|
||||
---
|
||||
|
||||
## 2. Condition Adjustment Multiplier
|
||||
|
||||
Normalizes prices across condition states:
|
||||
|
||||
$$
|
||||
M_{cond} = \exp\left( \alpha_c \cdot \sqrt{C_{target}} - \beta_c \right)
|
||||
$$
|
||||
|
||||
**Variables:**
|
||||
- $\alpha_c = 0.15$ = Condition sensitivity parameter
|
||||
- $\beta_c = 0.40$ = Baseline condition offset
|
||||
- $C_{target}$ = Target lot condition score
|
||||
|
||||
**Interpretation:**
|
||||
- $C = 10$ (mint): $M_{cond} = 1.48$ (48% premium over poor condition)
|
||||
- $C = 5$ (average): $M_{cond} = 0.91$
|
||||
|
||||
---
|
||||
|
||||
## 3. Time-Based Depreciation Model
|
||||
|
||||
For equipment/machinery with measurable lifespan:
|
||||
|
||||
$$
|
||||
V_{age} = V_{new} \cdot \left( 1 - \gamma \cdot \ln\left( 1 + \frac{Y_{current} - Y_{manu}}{Y_{expected}} \right) \right)
|
||||
$$
|
||||
|
||||
**Variables:**
|
||||
- $V_{new}$ = Original market value (€)
|
||||
- $\gamma = 0.25$ = Depreciation aggressivity factor
|
||||
- $Y_{current}$ = Current year
|
||||
- $Y_{manu}$ = Manufacturing year
|
||||
- $Y_{expected}$ = Expected useful life span (years)
|
||||
|
||||
**Example:** 10-year-old machinery with 25-year expected life retains 85% of value.
|
||||
|
||||
---
|
||||
|
||||
## 4. Provenance Premium Calculation
|
||||
|
||||
$$
|
||||
\Delta_{prov} = V_{base} \cdot \left( \eta_0 + \eta_1 \cdot \ln(1 + N_{docs}) \right)
|
||||
$$
|
||||
|
||||
**Variables:**
|
||||
- $V_{base}$ = Base valuation without provenance (€)
|
||||
- $N_{docs}$ = Number of verifiable provenance documents
|
||||
- $\eta_0 = 0.08$ = Base provenance premium (8%)
|
||||
- $\eta_1 = 0.035$ = Marginal document premium coefficient
|
||||
|
||||
---
|
||||
|
||||
## 5. Undervaluation Detection Score
|
||||
|
||||
Critical for identifying mispriced opportunities:
|
||||
|
||||
$$
|
||||
U_{score} = \frac{FMV - P_{current}}{FMV} \cdot \sigma_{market} \cdot \left( 1 + \frac{B_{velocity}}{B_{threshold}} \right) \cdot \ln\left( 1 + \frac{W_{watch}}{W_{bid}} \right)
|
||||
$$
|
||||
|
||||
**Variables:**
|
||||
- $P_{current}$ = Current bid price (€)
|
||||
- $\sigma_{market} \in [0,1]$ = Market volatility factor (from indices)
|
||||
- $B_{velocity}$ = Bids per hour (bph)
|
||||
- $B_{threshold} = 10$ bph = High-velocity threshold
|
||||
- $W_{watch}$ = Watch count
|
||||
- $W_{bid}$ = Bid count
|
||||
|
||||
**Trigger condition:** $U_{score} > 0.25$ (25% undervaluation) with confidence > 0.70
|
||||
|
||||
---
|
||||
|
||||
## 6. Bid Velocity Indicator (Competition Heat)
|
||||
|
||||
Measures real-time competitive intensity:
|
||||
|
||||
$$
|
||||
\Lambda_b(t) = \frac{dB}{dt} \cdot \exp\left( -\lambda_{cool} \cdot (t - t_{last}) \right)
|
||||
$$
|
||||
|
||||
**Variables:**
|
||||
- $\frac{dB}{dt}$ = Bid frequency derivative (bids/minute)
|
||||
- $\lambda_{cool} = 0.1$ = Cool-down decay constant
|
||||
- $t_{last}$ = Timestamp of last bid (minutes)
|
||||
|
||||
**Interpretation:**
|
||||
- $\Lambda_b > 5$ = **Hot lot** (bidding war likely)
|
||||
- $\Lambda_b < 0.5$ = **Cold lot** (potential sleeper)
|
||||
|
||||
---
|
||||
|
||||
## 7. Final Price Prediction Model
|
||||
|
||||
Composite machine learning-style formula:
|
||||
|
||||
$$
|
||||
\hat{P}_{final} = FMV \cdot \left( 1 + \epsilon_{bid} + \epsilon_{time} + \epsilon_{comp} \right)
|
||||
$$
|
||||
|
||||
**Error Components:**
|
||||
|
||||
- **Bid momentum error**:
|
||||
$$\epsilon_{bid} = \tanh\left( \phi_1 \cdot \Lambda_b - \phi_2 \cdot \frac{P_{current}}{FMV} \right)$$
|
||||
|
||||
- **Time-to-close error**:
|
||||
$$\epsilon_{time} = \psi \cdot \exp\left( -\frac{t_{close}}{30} \right)$$
|
||||
|
||||
- **Competition error**:
|
||||
$$\epsilon_{comp} = \rho \cdot \ln\left( 1 + \frac{W_{watch}}{50} \right)$$
|
||||
|
||||
**Parameters:**
|
||||
- $\phi_1 = 0.15$, $\phi_2 = 0.10$ = Bid momentum coefficients
|
||||
- $\psi = 0.20$ = Time pressure coefficient
|
||||
- $\rho = 0.08$ = Competition coefficient
|
||||
- $t_{close}$ = Minutes until close
|
||||
|
||||
**Confidence interval**:
|
||||
$$
|
||||
CI_{95\%} = \hat{P}_{final} \pm 1.96 \cdot \sigma_{residual}
|
||||
$$
|
||||
|
||||
---
|
||||
|
||||
## 8. Bidding Strategy Recommendation Engine
|
||||
|
||||
Optimal max bid and timing:
|
||||
|
||||
$$
|
||||
S_{max} =
|
||||
\begin{cases}
|
||||
FMV \cdot (1 - \theta_{agg}) & \text{if } U_{score} > 0.20 \\
|
||||
FMV \cdot (1 + \theta_{cons}) & \text{if } \Lambda_b > 3 \\
|
||||
\hat{P}_{final} - \delta_{margin} & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
**Variables:**
|
||||
- $\theta_{agg} = 0.10$ = Aggressive buyer discount target (10% below FMV)
|
||||
- $\theta_{cons} = 0.05$ = Conservative buyer overbid tolerance
|
||||
- $\delta_{margin} = €50$ = Minimum margin below predicted final
|
||||
|
||||
**Timing function**:
|
||||
$$
|
||||
t_{optimal} = t_{close} - \begin{cases}
|
||||
5 \text{ min} & \text{if } \Lambda_b < 1 \\
|
||||
30 \text{ sec} & \text{if } \Lambda_b > 5 \\
|
||||
10 \text{ min} & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
---
|
||||
|
||||
## Variable Reference Table
|
||||
|
||||
| Symbol | Variable | Unit | Data Source |
|
||||
|--------|----------|------|-------------|
|
||||
| $P_i$ | Comparable sale price | € | `bid_history.final` |
|
||||
| $C$ | Condition score | [0,10] | Image analysis + text parsing |
|
||||
| $T$ | Manufacturing year | Year | Lot description extraction |
|
||||
| $W_{watch}$ | Number of watchers | Count | Page metadata |
|
||||
| $\Lambda_b$ | Bid velocity | bids/min | `bid_history.timestamp` diff |
|
||||
| $t_{close}$ | Time until close | Minutes | `lots.closing_time` - NOW() |
|
||||
| $\sigma_{market}$ | Market volatility | [0,1] | `market_indices.price_change_30d` |
|
||||
| $N_{docs}$ | Provenance documents | Count | PDF link analysis |
|
||||
| $B_{velocity}$ | Bid acceleration | bph² | Second derivative of $\Lambda_b$ |
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation (Quarkus Pseudo-Code)
|
||||
|
||||
```java
|
||||
@Inject
|
||||
MLModelService mlModel;
|
||||
|
||||
public Valuation calculateFairMarketValue(Lot lot) {
|
||||
List<Comparable> comparables = db.findComparables(lot, minSimilarity=0.75, limit=20);
|
||||
|
||||
double weightedSum = 0.0;
|
||||
double weightSum = 0.0;
|
||||
|
||||
for (Comparable comp : comparables) {
|
||||
double wc = Math.exp(-0.693 * Math.abs(lot.getConditionScore() - comp.getConditionScore()));
|
||||
double wt = Math.exp(-0.048 * Math.abs(lot.getYear() - comp.getYear()));
|
||||
double wp = 1 + 0.15 * (lot.hasProvenance() ? 1 : 0 - comp.hasProvenance() ? 1 : 0);
|
||||
|
||||
double weight = wc * wt * wp;
|
||||
weightedSum += comp.getFinalPrice() * weight;
|
||||
weightSum += weight;
|
||||
}
|
||||
|
||||
double fm v = weightSum > 0 ? weightedSum / weightSum : lot.getEstimatedMin();
|
||||
|
||||
// Apply condition multiplier
|
||||
fm v *= Math.exp(0.15 * Math.sqrt(lot.getConditionScore()) - 0.40);
|
||||
|
||||
return new Valuation(fm v, calculateConfidence(comparables.size()));
|
||||
}
|
||||
|
||||
public BiddingStrategy getBiddingStrategy(String lotId) {
|
||||
var lot = db.getLot(lotId);
|
||||
var bidHistory = db.getBidHistory(lotId);
|
||||
var watchers = lot.getWatchCount();
|
||||
|
||||
// Analyze patterns
|
||||
boolean isSnipeTarget = watchers > 50 && bidHistory.size() < 5;
|
||||
boolean hasReserve = lot.getReservePrice() > 0;
|
||||
double bidVelocity = calculateBidVelocity(bidHistory);
|
||||
|
||||
// Strategy recommendation
|
||||
String strategy = isSnipeTarget ? "SNIPING_DETECTED" :
|
||||
(hasReserve && lot.getCurrentBid() < lot.getReservePrice() * 0.9) ? "RESERVE_AVOID" :
|
||||
bidVelocity > 5.0 ? "AGGRESSIVE_COMPETITION" : "STANDARD";
|
||||
|
||||
return new BiddingStrategy(
|
||||
strategy,
|
||||
calculateRecommendedMax(lot),
|
||||
isSnipeTarget ? "FINAL_30_SECONDS" : "FINAL_10_MINUTES",
|
||||
getCompetitionLevel(watchers, bidHistory.size())
|
||||
);
|
||||
}
|
||||
```
|
||||
```sqlite
|
||||
-- Core bidding intelligence
|
||||
ALTER TABLE lots ADD COLUMN starting_bid DECIMAL(12,2);
|
||||
ALTER TABLE lots ADD COLUMN estimated_min DECIMAL(12,2);
|
||||
ALTER TABLE lots ADD COLUMN estimated_max DECIMAL(12,2);
|
||||
ALTER TABLE lots ADD COLUMN reserve_price DECIMAL(12,2);
|
||||
ALTER TABLE lots ADD COLUMN watch_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE lots ADD COLUMN first_bid_time TEXT;
|
||||
ALTER TABLE lots ADD COLUMN last_bid_time TEXT;
|
||||
ALTER TABLE lots ADD COLUMN bid_velocity DECIMAL(5,2);
|
||||
|
||||
-- Bid history (critical)
|
||||
CREATE TABLE bid_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lot_id TEXT REFERENCES lots(lot_id),
|
||||
bid_amount DECIMAL(12,2) NOT NULL,
|
||||
bid_time TEXT NOT NULL,
|
||||
is_winning BOOLEAN DEFAULT FALSE,
|
||||
is_autobid BOOLEAN DEFAULT FALSE,
|
||||
bidder_id TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Valuation support
|
||||
ALTER TABLE lots ADD COLUMN condition_score DECIMAL(3,2);
|
||||
ALTER TABLE lots ADD COLUMN year_manufactured INTEGER;
|
||||
ALTER TABLE lots ADD COLUMN provenance TEXT;
|
||||
|
||||
CREATE TABLE comparable_sales (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lot_id TEXT REFERENCES lots(lot_id),
|
||||
comparable_lot_id TEXT,
|
||||
similarity_score DECIMAL(3,2),
|
||||
price_difference_percent DECIMAL(5,2)
|
||||
);
|
||||
|
||||
CREATE TABLE market_indices (
|
||||
category TEXT NOT NULL,
|
||||
manufacturer TEXT,
|
||||
avg_price DECIMAL(12,2),
|
||||
price_change_30d DECIMAL(5,2),
|
||||
PRIMARY KEY (category, manufacturer)
|
||||
);
|
||||
|
||||
-- Alert system
|
||||
CREATE TABLE price_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lot_id TEXT REFERENCES lots(lot_id),
|
||||
alert_type TEXT CHECK(alert_type IN ('UNDervalued', 'ACCELERATING', 'RESERVE_IN_SIGHT')),
|
||||
trigger_price DECIMAL(12,2),
|
||||
is_triggered BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
```
|
||||
@@ -1,20 +0,0 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Troostwijk Auction Monitor - Status Check (Windows)
|
||||
REM ============================================================================
|
||||
REM
|
||||
REM This script shows the current status and exits.
|
||||
REM
|
||||
REM Usage:
|
||||
REM check-status.bat
|
||||
REM
|
||||
REM ============================================================================
|
||||
|
||||
REM Set configuration
|
||||
set DATABASE_FILE=C:\mnt\okcomputer\output\cache.db
|
||||
set NOTIFICATION_CONFIG=desktop
|
||||
|
||||
REM Check status
|
||||
java -jar target\auctiora-1.0-SNAPSHOT-jar-with-dependencies.jar status
|
||||
|
||||
pause
|
||||
@@ -1,30 +0,0 @@
|
||||
@echo off
|
||||
if "%1"=="" (
|
||||
echo Error: Commit message is required
|
||||
echo Usage: persist "Your commit message"
|
||||
echo Example: persist "Fixed login bug"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Adding all changes...
|
||||
git add .
|
||||
if errorlevel 1 (
|
||||
echo Error: Failed to add changes
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Committing with message: "%*"
|
||||
git commit -a -m "%*"
|
||||
if errorlevel 1 (
|
||||
echo Error: Failed to commit
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Pushing to remote...
|
||||
git push
|
||||
if errorlevel 1 (
|
||||
echo Error: Failed to push
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo All operations completed successfully!
|
||||
@@ -1,27 +0,0 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Troostwijk Auction Monitor - Run Once (Windows)
|
||||
REM ============================================================================
|
||||
REM
|
||||
REM This script runs the complete workflow once and exits.
|
||||
REM Perfect for Windows Task Scheduler.
|
||||
REM
|
||||
REM Usage:
|
||||
REM run-once.bat
|
||||
REM
|
||||
REM Schedule in Task Scheduler:
|
||||
REM - Every 30 minutes: Data import
|
||||
REM - Every 1 hour: Image processing
|
||||
REM - Every 15 minutes: Bid monitoring
|
||||
REM
|
||||
REM ============================================================================
|
||||
|
||||
REM Set configuration
|
||||
set DATABASE_FILE=C:\mnt\okcomputer\output\cache.db
|
||||
set NOTIFICATION_CONFIG=desktop
|
||||
|
||||
REM Run the application once
|
||||
java -jar target\troostwijk-scraper-1.0-SNAPSHOT-jar-with-dependencies.jar once
|
||||
|
||||
REM Exit code for Task Scheduler
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -1,27 +0,0 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Troostwijk Auction Monitor - Workflow Runner (Windows)
|
||||
REM ============================================================================
|
||||
REM
|
||||
REM This script runs the auction monitor in workflow mode (continuous operation)
|
||||
REM with all scheduled tasks running automatically.
|
||||
REM
|
||||
REM Usage:
|
||||
REM run-workflow.bat
|
||||
REM
|
||||
REM ============================================================================
|
||||
|
||||
echo Starting Troostwijk Auction Monitor - Workflow Mode...
|
||||
echo.
|
||||
|
||||
REM Set configuration
|
||||
set DATABASE_FILE=C:\mnt\okcomputer\output\cache.db
|
||||
set NOTIFICATION_CONFIG=desktop
|
||||
|
||||
REM Optional: Set for email notifications
|
||||
REM set NOTIFICATION_CONFIG=smtp:your@gmail.com:app_password:your@gmail.com
|
||||
|
||||
REM Run the application
|
||||
java -jar target\auctiora-1.0-SNAPSHOT-jar-with-dependencies.jar workflow
|
||||
|
||||
pause
|
||||
@@ -1,71 +0,0 @@
|
||||
# ============================================================================
|
||||
# Troostwijk Auction Monitor - Windows Task Scheduler Setup
|
||||
# ============================================================================
|
||||
#
|
||||
# This PowerShell script creates scheduled tasks in Windows Task Scheduler
|
||||
# to run the auction monitor automatically.
|
||||
#
|
||||
# Usage:
|
||||
# Run PowerShell as Administrator, then:
|
||||
# .\setup-windows-task.ps1
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
Write-Host "=== Auctiora Monitor - Task Scheduler Setup ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Configuration
|
||||
$scriptPath = $PSScriptRoot
|
||||
$jarPath = Join-Path $scriptPath "target\auctiora-1.0-SNAPSHOT-jar-with-dependencies.jar"
|
||||
$javaExe = "java"
|
||||
|
||||
# Check if JAR exists
|
||||
if (-not (Test-Path $jarPath)) {
|
||||
Write-Host "ERROR: JAR file not found at: $jarPath" -ForegroundColor Red
|
||||
Write-Host "Please run 'mvn clean package' first." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Creating scheduled tasks..." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Task 1: Complete Workflow - Every 30 minutes
|
||||
$task1Name = "TroostwijkMonitor-Workflow"
|
||||
$task1Description = "Runs complete auction monitoring workflow every 30 minutes"
|
||||
$task1Action = New-ScheduledTaskAction -Execute $javaExe -Argument "-jar `"$jarPath`" once" -WorkingDirectory $scriptPath
|
||||
$task1Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration ([TimeSpan]::MaxValue)
|
||||
$task1Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
|
||||
try {
|
||||
Register-ScheduledTask -TaskName $task1Name -Action $task1Action -Trigger $task1Trigger -Settings $task1Settings -Description $task1Description -Force
|
||||
Write-Host "[✓] Created task: $task1Name (every 30 min)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[✗] Failed to create task: $task1Name" -ForegroundColor Red
|
||||
Write-Host " Error: $_" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Task 2: Status Check - Every 6 hours
|
||||
$task2Name = "TroostwijkMonitor-StatusCheck"
|
||||
$task2Description = "Checks auction monitoring status every 6 hours"
|
||||
$task2Action = New-ScheduledTaskAction -Execute $javaExe -Argument "-jar `"$jarPath`" status" -WorkingDirectory $scriptPath
|
||||
$task2Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 6) -RepetitionDuration ([TimeSpan]::MaxValue)
|
||||
$task2Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
|
||||
try {
|
||||
Register-ScheduledTask -TaskName $task2Name -Action $task2Action -Trigger $task2Trigger -Settings $task2Settings -Description $task2Description -Force
|
||||
Write-Host "[✓] Created task: $task2Name (every 6 hours)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[✗] Failed to create task: $task2Name" -ForegroundColor Red
|
||||
Write-Host " Error: $_" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Setup Complete ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Created tasks:" -ForegroundColor White
|
||||
Write-Host " 1. $task1Name - Runs every 30 minutes" -ForegroundColor Gray
|
||||
Write-Host " 2. $task2Name - Runs every 6 hours" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host "To view tasks: Open Task Scheduler and look for 'TroostwijkMonitor-*'" -ForegroundColor Yellow
|
||||
Write-Host "To remove tasks: Run 'Unregister-ScheduledTask -TaskName TroostwijkMonitor-*'" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
206
scripts/README.md
Normal file
206
scripts/README.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Auctiora Scripts
|
||||
|
||||
Utility scripts for managing the Auctiora auction monitoring system.
|
||||
|
||||
## 📦 Available Scripts
|
||||
|
||||
### 1. Production Data Sync
|
||||
|
||||
Sync production database and images from `athena.lan` to your local development environment.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
**Linux/Mac (Bash)**:
|
||||
```bash
|
||||
# Make executable (first time only)
|
||||
chmod +x scripts/sync-production-data.sh
|
||||
|
||||
# Sync database only
|
||||
./scripts/sync-production-data.sh --db-only
|
||||
|
||||
# Sync everything
|
||||
./scripts/sync-production-data.sh --all
|
||||
|
||||
# Sync images only
|
||||
./scripts/sync-production-data.sh --images-only
|
||||
```
|
||||
|
||||
## 🔧 Prerequisites
|
||||
|
||||
### Required
|
||||
- **SSH Client**: OpenSSH or equivalent
|
||||
- Windows: Built-in on Windows 10+, or install [Git Bash](https://git-scm.com/downloads)
|
||||
- Linux/Mac: Pre-installed
|
||||
- **SCP**: Secure copy (usually comes with SSH)
|
||||
- **SSH Access**: SSH key configured for `tour@athena.lan`
|
||||
|
||||
### Optional
|
||||
- **rsync**: For efficient incremental image sync
|
||||
- Windows: Install via [WSL](https://docs.microsoft.com/en-us/windows/wsl/install) or [Cygwin](https://www.cygwin.com/)
|
||||
- Linux/Mac: Usually pre-installed
|
||||
- **sqlite3**: For showing database statistics
|
||||
- Windows: Download from [sqlite.org](https://www.sqlite.org/download.html)
|
||||
- Linux: `sudo apt install sqlite3`
|
||||
- Mac: Pre-installed
|
||||
|
||||
## 📊 What Gets Synced
|
||||
|
||||
### Database (`cache.db`)
|
||||
- **Size**: ~8.9 GB (as of Dec 2024)
|
||||
- **Contains**:
|
||||
- Auctions metadata
|
||||
- Lots (kavels) with bid information
|
||||
- Images metadata and URLs
|
||||
- HTTP cache for scraper
|
||||
- **Local Path**: `c:\mnt\okcomputer\cache.db`
|
||||
|
||||
### Images Directory
|
||||
- **Size**: Varies (can be large)
|
||||
- **Contains**:
|
||||
- Downloaded lot images
|
||||
- Organized by lot ID
|
||||
- **Local Path**: `c:\mnt\okcomputer\images\`
|
||||
|
||||
## 🚀 Usage Examples
|
||||
|
||||
## 📁 File Locations
|
||||
|
||||
### Remote (Production)
|
||||
```
|
||||
athena.lan
|
||||
├── Docker Volume: shared-auction-data
|
||||
│ ├── /data/cache.db (SQLite database)
|
||||
│ └── /data/images/ (Image files)
|
||||
└── /tmp/ (Temporary staging area)
|
||||
```
|
||||
|
||||
### Local (Development)
|
||||
```
|
||||
c:\mnt\okcomputer\
|
||||
├── cache.db (SQLite database)
|
||||
├── cache.db.backup-* (Automatic backups)
|
||||
└── images\ (Image files)
|
||||
```
|
||||
|
||||
## 🔒 Safety Features
|
||||
|
||||
### Automatic Backups
|
||||
- Existing local database is automatically backed up before sync
|
||||
- Backup format: `cache.db.backup-YYYYMMDD-HHMMSS`
|
||||
- Keep recent backups manually or clean up old ones
|
||||
|
||||
### Confirmation Prompts
|
||||
- PowerShell script prompts for confirmation (unless `-Force` is used)
|
||||
- Shows configuration before executing
|
||||
- Safe to cancel at any time
|
||||
|
||||
### Error Handling
|
||||
- Validates SSH connection before starting
|
||||
- Cleans up temporary files on remote server
|
||||
- Reports clear error messages
|
||||
|
||||
## ⚡ Performance Tips
|
||||
|
||||
### Faster Image Sync with rsync
|
||||
Install rsync for incremental image sync (only new/changed files):
|
||||
|
||||
**Windows (WSL)**:
|
||||
```powershell
|
||||
wsl --install
|
||||
wsl -d Ubuntu
|
||||
sudo apt install rsync
|
||||
```
|
||||
|
||||
**Windows (Chocolatey)**:
|
||||
```powershell
|
||||
choco install rsync
|
||||
```
|
||||
|
||||
**Benefit**: First sync downloads everything, subsequent syncs only transfer changed files.
|
||||
|
||||
Images can be synced separately when needed for image processing tests.
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### SSH Connection Issues
|
||||
```powershell
|
||||
# Test SSH connection
|
||||
ssh tour@athena.lan "echo 'Connection OK'"
|
||||
|
||||
# Check SSH key
|
||||
ssh-add -l
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
```bash
|
||||
# Add SSH key (Linux/Mac)
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-add ~/.ssh/id_rsa
|
||||
|
||||
# Windows: Use PuTTY or OpenSSH for Windows
|
||||
```
|
||||
|
||||
### Database Locked Error
|
||||
```powershell
|
||||
# Make sure no other process is using the database
|
||||
Get-Process | Where-Object {$_.Path -like "*java*"} | Stop-Process
|
||||
|
||||
# Or restart the monitor
|
||||
```
|
||||
|
||||
### Slow Image Sync
|
||||
- Use rsync instead of scp (see Performance Tips)
|
||||
- Consider syncing only database for code development
|
||||
- Images only needed for object detection testing
|
||||
|
||||
## 📝 Script Details
|
||||
|
||||
### sync-production-data.sh (Bash)
|
||||
- **Platform**: Linux, Mac, Git Bash on Windows
|
||||
- **Best for**: Unix-like environments
|
||||
- **Features**: Color output, progress bars, statistics
|
||||
|
||||
## 🔄 Automation
|
||||
|
||||
### Linux/Mac Cron
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
|
||||
# Add daily sync at 7 AM
|
||||
0 7 * * * /path/to/auctiora/scripts/sync-production-data.sh --db-only
|
||||
```
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
### Getting Help
|
||||
```bash
|
||||
# Bash
|
||||
./scripts/sync-production-data.sh --help
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
```powershell
|
||||
# Check database size
|
||||
ls c:\mnt\okcomputer\cache.db -h
|
||||
|
||||
# View database contents
|
||||
sqlite3 c:\mnt\okcomputer\cache.db
|
||||
.tables
|
||||
.schema lots
|
||||
SELECT COUNT(*) FROM lots;
|
||||
.quit
|
||||
|
||||
# Check image count
|
||||
(Get-ChildItem c:\mnt\okcomputer\images -Recurse -File).Count
|
||||
```
|
||||
|
||||
## 📚 Related Documentation
|
||||
- [Database Architecture](../wiki/DATABASE_ARCHITECTURE.md)
|
||||
- [Integration Flowchart](../docs/INTEGRATION_FLOWCHART.md)
|
||||
- [Main README](../README.md)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 2025
|
||||
**Maintainer**: Auctiora Development Team
|
||||
160
scripts/cleanup-database.sh
Normal file
160
scripts/cleanup-database.sh
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Database Cleanup Utility
|
||||
#
|
||||
# Removes invalid/old data from the local database
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/cleanup-database.sh [--dry-run]
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Show what would be deleted without actually deleting
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
LOCAL_DB_PATH="${1:-c:/mnt/okcomputer/cache.db}"
|
||||
DRY_RUN=false
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Parse arguments
|
||||
if [ "$1" = "--dry-run" ] || [ "$2" = "--dry-run" ]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||
grep '^#' "$0" | sed 's/^# \?//'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Database Cleanup - Auctiora Monitor ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
if [ ! -f "${LOCAL_DB_PATH}" ]; then
|
||||
echo -e "${RED}Error: Database not found at ${LOCAL_DB_PATH}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup database before cleanup
|
||||
if [ "$DRY_RUN" = false ]; then
|
||||
BACKUP_PATH="${LOCAL_DB_PATH}.backup-before-cleanup-$(date +%Y%m%d-%H%M%S)"
|
||||
echo -e "${YELLOW}Creating backup: ${BACKUP_PATH}${NC}"
|
||||
cp "${LOCAL_DB_PATH}" "${BACKUP_PATH}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Show current state
|
||||
echo -e "${BLUE}Current database state:${NC}"
|
||||
sqlite3 "${LOCAL_DB_PATH}" <<EOF
|
||||
.mode box
|
||||
SELECT
|
||||
'Total lots' as metric,
|
||||
COUNT(*) as count
|
||||
FROM lots
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Valid lots (with auction_id)',
|
||||
COUNT(*)
|
||||
FROM lots
|
||||
WHERE auction_id IS NOT NULL AND auction_id != ''
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Invalid lots (missing auction_id)',
|
||||
COUNT(*)
|
||||
FROM lots
|
||||
WHERE auction_id IS NULL OR auction_id = '';
|
||||
EOF
|
||||
echo ""
|
||||
|
||||
# Count items to be deleted
|
||||
echo -e "${YELLOW}Analyzing data to clean up...${NC}"
|
||||
|
||||
INVALID_LOTS=$(sqlite3 "${LOCAL_DB_PATH}" "SELECT COUNT(*) FROM lots WHERE auction_id IS NULL OR auction_id = '';")
|
||||
ORPHANED_IMAGES=$(sqlite3 "${LOCAL_DB_PATH}" "SELECT COUNT(*) FROM images WHERE lot_id NOT IN (SELECT lot_id FROM lots);")
|
||||
|
||||
echo -e " ${RED}→ Invalid lots to delete: ${INVALID_LOTS}${NC}"
|
||||
echo -e " ${YELLOW}→ Orphaned images to delete: ${ORPHANED_IMAGES}${NC}"
|
||||
echo ""
|
||||
|
||||
if [ "$INVALID_LOTS" -eq 0 ] && [ "$ORPHANED_IMAGES" -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Database is clean! No cleanup needed.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo -e "${BLUE}DRY RUN MODE - No changes will be made${NC}"
|
||||
echo ""
|
||||
echo "Would delete:"
|
||||
echo " - $INVALID_LOTS invalid lots"
|
||||
echo " - $ORPHANED_IMAGES orphaned images"
|
||||
echo ""
|
||||
echo "Run without --dry-run to perform cleanup"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Confirm cleanup
|
||||
echo -e "${YELLOW}This will permanently delete the above records.${NC}"
|
||||
read -p "Continue? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Cleanup cancelled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Perform cleanup
|
||||
echo ""
|
||||
echo -e "${YELLOW}Cleaning up database...${NC}"
|
||||
|
||||
# Delete invalid lots
|
||||
if [ "$INVALID_LOTS" -gt 0 ]; then
|
||||
echo -e " ${BLUE}[1/2] Deleting invalid lots...${NC}"
|
||||
sqlite3 "${LOCAL_DB_PATH}" "DELETE FROM lots WHERE auction_id IS NULL OR auction_id = '';"
|
||||
echo -e " ${GREEN}✓ Deleted ${INVALID_LOTS} invalid lots${NC}"
|
||||
fi
|
||||
|
||||
# Delete orphaned images
|
||||
if [ "$ORPHANED_IMAGES" -gt 0 ]; then
|
||||
echo -e " ${BLUE}[2/2] Deleting orphaned images...${NC}"
|
||||
sqlite3 "${LOCAL_DB_PATH}" "DELETE FROM images WHERE lot_id NOT IN (SELECT lot_id FROM lots);"
|
||||
echo -e " ${GREEN}✓ Deleted ${ORPHANED_IMAGES} orphaned images${NC}"
|
||||
fi
|
||||
|
||||
# Vacuum database to reclaim space
|
||||
echo -e " ${BLUE}[3/3] Compacting database...${NC}"
|
||||
sqlite3 "${LOCAL_DB_PATH}" "VACUUM;"
|
||||
echo -e " ${GREEN}✓ Database compacted${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Cleanup completed successfully ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Show final state
|
||||
echo -e "${BLUE}Final database state:${NC}"
|
||||
sqlite3 "${LOCAL_DB_PATH}" <<EOF
|
||||
.mode box
|
||||
SELECT
|
||||
'Total lots' as metric,
|
||||
COUNT(*) as count
|
||||
FROM lots
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Total images',
|
||||
COUNT(*)
|
||||
FROM images;
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
DB_SIZE=$(du -h "${LOCAL_DB_PATH}" | cut -f1)
|
||||
echo -e "${BLUE}Database size: ${DB_SIZE}${NC}"
|
||||
echo ""
|
||||
200
scripts/sync-production-data.sh
Normal file
200
scripts/sync-production-data.sh
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Sync Production Data to Local
|
||||
#
|
||||
# This script copies the production SQLite database and images from the remote
|
||||
# server (athena.lan) to your local development environment.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/sync-production-data.sh [--db-only|--images-only|--all]
|
||||
#
|
||||
# Options:
|
||||
# --db-only Only sync the database (default)
|
||||
# --images-only Only sync the images
|
||||
# --all Sync both database and images
|
||||
# --help Show this help message
|
||||
#
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Configuration
|
||||
REMOTE_HOST="tour@athena.lan"
|
||||
REMOTE_VOLUME="shared-auction-data"
|
||||
LOCAL_DB_PATH="c:/mnt/okcomputer/output/cache.db"
|
||||
LOCAL_IMAGES_PATH="c:/mnt/okcomputer/images"
|
||||
REMOTE_TMP="/tmp"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Parse arguments
|
||||
SYNC_MODE="db" # Default: database only
|
||||
|
||||
case "${1:-}" in
|
||||
--db-only)
|
||||
SYNC_MODE="db"
|
||||
;;
|
||||
--images-only)
|
||||
SYNC_MODE="images"
|
||||
;;
|
||||
--all)
|
||||
SYNC_MODE="all"
|
||||
;;
|
||||
--help|-h)
|
||||
grep '^#' "$0" | sed 's/^# \?//'
|
||||
exit 0
|
||||
;;
|
||||
"")
|
||||
SYNC_MODE="db"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Error: Unknown option '$1'${NC}"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Production Data Sync - Auctiora Monitor ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Function to sync database
|
||||
sync_database() {
|
||||
echo -e "${YELLOW}[1/3] Copying database from Docker volume to /tmp...${NC}"
|
||||
ssh ${REMOTE_HOST} "docker run --rm -v ${REMOTE_VOLUME}:/data -v ${REMOTE_TMP}:${REMOTE_TMP} alpine cp /data/cache.db ${REMOTE_TMP}/cache.db"
|
||||
|
||||
echo -e "${YELLOW}[2/3] Downloading database from remote server...${NC}"
|
||||
# Create backup and remove old local database
|
||||
if [ -f "${LOCAL_DB_PATH}" ]; then
|
||||
BACKUP_PATH="${LOCAL_DB_PATH}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||
echo -e "${BLUE} Backing up existing database to: ${BACKUP_PATH}${NC}"
|
||||
cp "${LOCAL_DB_PATH}" "${BACKUP_PATH}"
|
||||
|
||||
echo -e "${BLUE} Removing old local database...${NC}"
|
||||
rm -f "${LOCAL_DB_PATH}"
|
||||
fi
|
||||
|
||||
# Download new database
|
||||
scp ${REMOTE_HOST}:${REMOTE_TMP}/cache.db "${LOCAL_DB_PATH}"
|
||||
|
||||
echo -e "${YELLOW}[3/3] Cleaning up remote /tmp...${NC}"
|
||||
ssh ${REMOTE_HOST} "rm -f ${REMOTE_TMP}/cache.db"
|
||||
|
||||
# Show database info
|
||||
DB_SIZE=$(du -h "${LOCAL_DB_PATH}" | cut -f1)
|
||||
echo -e "${GREEN}✓ Database synced successfully (${DB_SIZE})${NC}"
|
||||
|
||||
# Show table counts
|
||||
echo -e "${BLUE} Database statistics:${NC}"
|
||||
sqlite3 "${LOCAL_DB_PATH}" <<EOF
|
||||
.mode box
|
||||
SELECT
|
||||
'auctions' as table_name, COUNT(*) as count FROM auctions
|
||||
UNION ALL
|
||||
SELECT 'lots', COUNT(*) FROM lots
|
||||
UNION ALL
|
||||
SELECT 'images', COUNT(*) FROM images
|
||||
UNION ALL
|
||||
SELECT 'cache', COUNT(*) FROM cache;
|
||||
EOF
|
||||
|
||||
# Show data quality report
|
||||
echo -e "${BLUE} Data quality:${NC}"
|
||||
sqlite3 "${LOCAL_DB_PATH}" <<EOF
|
||||
.mode box
|
||||
SELECT
|
||||
'Valid lots' as metric,
|
||||
COUNT(*) as count,
|
||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM lots), 2) || '%' as percentage
|
||||
FROM lots
|
||||
WHERE auction_id IS NOT NULL AND auction_id != ''
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Invalid lots (missing auction_id)',
|
||||
COUNT(*),
|
||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM lots), 2) || '%'
|
||||
FROM lots
|
||||
WHERE auction_id IS NULL OR auction_id = ''
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Lots with intelligence fields',
|
||||
COUNT(*),
|
||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM lots), 2) || '%'
|
||||
FROM lots
|
||||
WHERE followers_count IS NOT NULL OR estimated_min IS NOT NULL;
|
||||
EOF
|
||||
}
|
||||
|
||||
# Function to sync images
|
||||
sync_images() {
|
||||
echo -e "${YELLOW}[1/4] Getting image directory structure from Docker volume...${NC}"
|
||||
|
||||
# Create local images directory if it doesn't exist
|
||||
mkdir -p "${LOCAL_IMAGES_PATH}"
|
||||
|
||||
echo -e "${YELLOW}[2/4] Copying images from Docker volume to /tmp...${NC}"
|
||||
# Copy entire images directory from volume to /tmp
|
||||
ssh ${REMOTE_HOST} "docker run --rm -v ${REMOTE_VOLUME}:/data -v ${REMOTE_TMP}:${REMOTE_TMP} alpine sh -c 'mkdir -p ${REMOTE_TMP}/auction-images && cp -r /data/images/* ${REMOTE_TMP}/auction-images/ 2>/dev/null || true'"
|
||||
|
||||
echo -e "${YELLOW}[3/4] Syncing images to local directory (this may take a while)...${NC}"
|
||||
# Use rsync for efficient incremental sync
|
||||
if command -v rsync &> /dev/null; then
|
||||
echo -e "${BLUE} Using rsync for efficient transfer...${NC}"
|
||||
rsync -avz --progress ${REMOTE_HOST}:${REMOTE_TMP}/auction-images/ "${LOCAL_IMAGES_PATH}/"
|
||||
else
|
||||
echo -e "${BLUE} Using scp for transfer (install rsync for faster incremental sync)...${NC}"
|
||||
scp -r ${REMOTE_HOST}:${REMOTE_TMP}/auction-images/* "${LOCAL_IMAGES_PATH}/"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}[4/4] Cleaning up remote /tmp...${NC}"
|
||||
ssh ${REMOTE_HOST} "rm -rf ${REMOTE_TMP}/auction-images"
|
||||
|
||||
# Show image stats
|
||||
IMAGE_COUNT=$(find "${LOCAL_IMAGES_PATH}" -type f 2>/dev/null | wc -l)
|
||||
IMAGE_SIZE=$(du -sh "${LOCAL_IMAGES_PATH}" 2>/dev/null | cut -f1)
|
||||
echo -e "${GREEN}✓ Images synced successfully${NC}"
|
||||
echo -e "${BLUE} Total images: ${IMAGE_COUNT}${NC}"
|
||||
echo -e "${BLUE} Total size: ${IMAGE_SIZE}${NC}"
|
||||
}
|
||||
|
||||
# Execute sync based on mode
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
case "$SYNC_MODE" in
|
||||
db)
|
||||
echo -e "${BLUE}Mode: Database only${NC}"
|
||||
echo ""
|
||||
sync_database
|
||||
;;
|
||||
images)
|
||||
echo -e "${BLUE}Mode: Images only${NC}"
|
||||
echo ""
|
||||
sync_images
|
||||
;;
|
||||
all)
|
||||
echo -e "${BLUE}Mode: Database + Images${NC}"
|
||||
echo ""
|
||||
sync_database
|
||||
echo ""
|
||||
sync_images
|
||||
;;
|
||||
esac
|
||||
|
||||
END_TIME=$(date +%s)
|
||||
DURATION=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Sync completed successfully in ${DURATION} seconds ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Next steps:${NC}"
|
||||
echo -e " 1. Verify data: sqlite3 ${LOCAL_DB_PATH} 'SELECT COUNT(*) FROM lots;'"
|
||||
echo -e " 2. Start monitor: mvn quarkus:dev"
|
||||
echo -e " 3. Open dashboard: http://localhost:8080"
|
||||
echo ""
|
||||
@@ -5,10 +5,17 @@ import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.logging.Logger;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.stream.Collectors;
|
||||
/**
|
||||
* REST API for Auction Monitor control and status.
|
||||
* Provides endpoints for:
|
||||
@@ -34,7 +41,10 @@ public class AuctionMonitorResource {
|
||||
|
||||
@Inject
|
||||
RateLimitedHttpClient httpClient;
|
||||
|
||||
|
||||
@Inject
|
||||
LotEnrichmentService enrichmentService;
|
||||
|
||||
/**
|
||||
* GET /api/monitor/status
|
||||
* Returns current monitoring status
|
||||
@@ -49,11 +59,14 @@ public class AuctionMonitorResource {
|
||||
status.put("lots", db.getAllLots().size());
|
||||
status.put("images", db.getImageCount());
|
||||
|
||||
// Count closing soon
|
||||
// Count closing soon (within 30 minutes, excluding already-closed)
|
||||
var closingSoon = 0;
|
||||
for (var lot : db.getAllLots()) {
|
||||
if (lot.closingTime() != null && lot.minutesUntilClose() < 30) {
|
||||
closingSoon++;
|
||||
if (lot.closingTime() != null) {
|
||||
long minutes = lot.minutesUntilClose();
|
||||
if (minutes > 0 && minutes < 30) {
|
||||
closingSoon++;
|
||||
}
|
||||
}
|
||||
}
|
||||
status.put("closingSoon", closingSoon);
|
||||
@@ -89,22 +102,68 @@ public class AuctionMonitorResource {
|
||||
var activeLots = 0;
|
||||
var lotsWithBids = 0;
|
||||
double totalBids = 0;
|
||||
|
||||
var hotLots = 0;
|
||||
var sleeperLots = 0;
|
||||
var bargainLots = 0;
|
||||
var lotsClosing1h = 0;
|
||||
var lotsClosing6h = 0;
|
||||
double totalBidVelocity = 0;
|
||||
int velocityCount = 0;
|
||||
|
||||
for (var lot : lots) {
|
||||
if (lot.closingTime() != null && lot.minutesUntilClose() > 0) {
|
||||
long minutesLeft = lot.closingTime() != null ? lot.minutesUntilClose() : Long.MAX_VALUE;
|
||||
|
||||
if (lot.closingTime() != null && minutesLeft > 0) {
|
||||
activeLots++;
|
||||
|
||||
// Time-based counts
|
||||
if (minutesLeft < 60) lotsClosing1h++;
|
||||
if (minutesLeft < 360) lotsClosing6h++;
|
||||
}
|
||||
|
||||
if (lot.currentBid() > 0) {
|
||||
lotsWithBids++;
|
||||
totalBids += lot.currentBid();
|
||||
}
|
||||
|
||||
// Intelligence metrics (require GraphQL enrichment)
|
||||
if (lot.followersCount() != null && lot.followersCount() > 20) {
|
||||
hotLots++;
|
||||
}
|
||||
if (lot.isSleeperLot()) {
|
||||
sleeperLots++;
|
||||
}
|
||||
if (lot.isBelowEstimate()) {
|
||||
bargainLots++;
|
||||
}
|
||||
|
||||
// Bid velocity
|
||||
if (lot.bidVelocity() != null && lot.bidVelocity() > 0) {
|
||||
totalBidVelocity += lot.bidVelocity();
|
||||
velocityCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Calculate bids per hour (average velocity across all lots with velocity data)
|
||||
double bidsPerHour = velocityCount > 0 ? totalBidVelocity / velocityCount : 0;
|
||||
|
||||
stats.put("activeLots", activeLots);
|
||||
stats.put("lotsWithBids", lotsWithBids);
|
||||
stats.put("totalBidValue", String.format("€%.2f", totalBids));
|
||||
stats.put("averageBid", lotsWithBids > 0 ? String.format("€%.2f", totalBids / lotsWithBids) : "€0.00");
|
||||
|
||||
|
||||
// Bidding intelligence
|
||||
stats.put("bidsPerHour", String.format("%.1f", bidsPerHour));
|
||||
stats.put("hotLots", hotLots);
|
||||
stats.put("sleeperLots", sleeperLots);
|
||||
stats.put("bargainLots", bargainLots);
|
||||
stats.put("lotsClosing1h", lotsClosing1h);
|
||||
stats.put("lotsClosing6h", lotsClosing6h);
|
||||
|
||||
// Conversion rate
|
||||
double conversionRate = activeLots > 0 ? (lotsWithBids * 100.0 / activeLots) : 0;
|
||||
stats.put("conversionRate", String.format("%.1f%%", conversionRate));
|
||||
|
||||
return Response.ok(stats).build();
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -115,6 +174,49 @@ public class AuctionMonitorResource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/closing-soon
|
||||
* Returns lots closing within the next specified hours (default: 24 hours)
|
||||
*/
|
||||
@GET
|
||||
@Path("/closing-soon")
|
||||
public Response getClosingSoon(@QueryParam("hours") @DefaultValue("24") int hours) {
|
||||
try {
|
||||
var lots = db.getAllLots();
|
||||
var closingSoon = lots.stream()
|
||||
.filter(lot -> lot.closingTime() != null)
|
||||
.filter(lot -> lot.minutesUntilClose() > 0 && lot.minutesUntilClose() <= hours * 60)
|
||||
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
|
||||
.limit(100)
|
||||
.toList();
|
||||
|
||||
return Response.ok(closingSoon).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get closing soon lots", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/lots/{lotId}/bid-history
|
||||
* Returns bid history for a specific lot
|
||||
*/
|
||||
@GET
|
||||
@Path("/lots/{lotId}/bid-history")
|
||||
public Response getBidHistory(@PathParam("lotId") String lotId) {
|
||||
try {
|
||||
var history = db.getBidHistory(lotId);
|
||||
return Response.ok(history).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get bid history for lot {}", lotId, e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/monitor/trigger/scraper-import
|
||||
* Manually trigger scraper import workflow
|
||||
@@ -186,7 +288,37 @@ public class AuctionMonitorResource {
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* POST /api/monitor/trigger/graphql-enrichment
|
||||
* Manually trigger GraphQL enrichment for all lots or lots closing soon
|
||||
*/
|
||||
@POST
|
||||
@Path("/trigger/graphql-enrichment")
|
||||
public Response triggerGraphQLEnrichment(@QueryParam("hoursUntilClose") @DefaultValue("24") int hours) {
|
||||
try {
|
||||
int enriched;
|
||||
if (hours > 0) {
|
||||
enriched = enrichmentService.enrichClosingSoonLots(hours);
|
||||
return Response.ok(Map.of(
|
||||
"message", "GraphQL enrichment triggered for lots closing within " + hours + " hours",
|
||||
"enrichedCount", enriched
|
||||
)).build();
|
||||
} else {
|
||||
enriched = enrichmentService.enrichAllActiveLots();
|
||||
return Response.ok(Map.of(
|
||||
"message", "GraphQL enrichment triggered for all lots",
|
||||
"enrichedCount", enriched
|
||||
)).build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to trigger GraphQL enrichment", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/auctions
|
||||
* Returns list of all auctions
|
||||
@@ -236,9 +368,14 @@ public class AuctionMonitorResource {
|
||||
try {
|
||||
var allLots = db.getActiveLots();
|
||||
var closingSoon = allLots.stream()
|
||||
.filter(lot -> lot.closingTime() != null && lot.minutesUntilClose() < minutes)
|
||||
.filter(lot -> lot.closingTime() != null)
|
||||
.filter(lot -> {
|
||||
long minutesLeft = lot.minutesUntilClose();
|
||||
return minutesLeft > 0 && minutesLeft < minutes;
|
||||
})
|
||||
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
|
||||
.toList();
|
||||
|
||||
|
||||
return Response.ok(closingSoon).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get closing lots", e);
|
||||
@@ -359,4 +496,439 @@ public class AuctionMonitorResource {
|
||||
.build();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* GET /api/monitor/charts/country-distribution
|
||||
* Returns dynamic country distribution for charts
|
||||
*/
|
||||
@GET
|
||||
@Path("/charts/country-distribution")
|
||||
public Response getCountryDistribution() {
|
||||
try {
|
||||
var auctions = db.getAllAuctions();
|
||||
Map<String, Long> distribution = auctions.stream()
|
||||
.filter(a -> a.country() != null && !a.country().isEmpty())
|
||||
.collect(Collectors.groupingBy(
|
||||
AuctionInfo::country,
|
||||
Collectors.counting()
|
||||
));
|
||||
|
||||
return Response.ok(distribution).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get country distribution", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/charts/category-distribution
|
||||
* Returns dynamic category distribution with intelligence for charts
|
||||
*/
|
||||
@GET
|
||||
@Path("/charts/category-distribution")
|
||||
public Response getCategoryDistribution() {
|
||||
try {
|
||||
var lots = db.getAllLots();
|
||||
|
||||
// Category distribution
|
||||
Map<String, Long> distribution = lots.stream()
|
||||
.filter(l -> l.category() != null && !l.category().isEmpty())
|
||||
.collect(Collectors.groupingBy(
|
||||
l -> l.category().length() > 20 ? l.category().substring(0, 20) + "..." : l.category(),
|
||||
Collectors.counting()
|
||||
));
|
||||
|
||||
// Find top category by count
|
||||
var topCategory = distribution.entrySet().stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse("N/A");
|
||||
|
||||
// Calculate average bids per category
|
||||
Map<String, Double> avgBidsByCategory = lots.stream()
|
||||
.filter(l -> l.category() != null && !l.category().isEmpty() && l.currentBid() > 0)
|
||||
.collect(Collectors.groupingBy(
|
||||
l -> l.category().length() > 20 ? l.category().substring(0, 20) + "..." : l.category(),
|
||||
Collectors.averagingDouble(Lot::currentBid)
|
||||
));
|
||||
|
||||
double overallAvgBid = lots.stream()
|
||||
.filter(l -> l.currentBid() > 0)
|
||||
.mapToDouble(Lot::currentBid)
|
||||
.average()
|
||||
.orElse(0.0);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("distribution", distribution);
|
||||
response.put("topCategory", topCategory);
|
||||
response.put("categoryCount", distribution.size());
|
||||
response.put("averageBidOverall", String.format("€%.2f", overallAvgBid));
|
||||
response.put("avgBidsByCategory", avgBidsByCategory);
|
||||
|
||||
return Response.ok(response).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get category distribution", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/charts/bidding-trend
|
||||
* Returns time series data for last N hours
|
||||
*/
|
||||
@GET
|
||||
@Path("/charts/bidding-trend")
|
||||
public Response getBiddingTrend(@QueryParam("hours") @DefaultValue("24") int hours) {
|
||||
try {
|
||||
var lots = db.getAllLots();
|
||||
Map<Integer, TrendHour> trends = new HashMap<>();
|
||||
|
||||
// Initialize hours
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (int i = hours - 1; i >= 0; i--) {
|
||||
LocalDateTime hour = now.minusHours(i);
|
||||
int hourKey = hour.getHour();
|
||||
trends.put(hourKey, new TrendHour(hourKey, 0, 0));
|
||||
}
|
||||
|
||||
// Count lots and bids per hour (mock implementation - in real app, use timestamp data)
|
||||
// This is a simplified version - you'd need actual timestamps in DB
|
||||
for (var lot : lots) {
|
||||
if (lot.closingTime() != null) {
|
||||
int hour = lot.closingTime().getHour();
|
||||
TrendHour trend = trends.getOrDefault(hour, new TrendHour(hour, 0, 0));
|
||||
trend.lots++;
|
||||
if (lot.currentBid() > 0) trend.bids++;
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok(trends.values()).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get bidding trend", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/charts/insights
|
||||
* Returns intelligent insights
|
||||
*/
|
||||
@GET
|
||||
@Path("/charts/insights")
|
||||
public Response getInsights() {
|
||||
try {
|
||||
var lots = db.getAllLots();
|
||||
var auctions = db.getAllAuctions();
|
||||
|
||||
List<Map<String, String>> insights = new ArrayList<>();
|
||||
|
||||
// Calculate insights
|
||||
long criticalCount = lots.stream().filter(l -> l.minutesUntilClose() < 30).count();
|
||||
if (criticalCount > 10) {
|
||||
insights.add(Map.of(
|
||||
"icon", "fa-exclamation-circle",
|
||||
"title", criticalCount + " lots closing soon",
|
||||
"description", "High urgency items require attention"
|
||||
));
|
||||
}
|
||||
|
||||
double bidRate = lots.stream().filter(l -> l.currentBid() > 0).count() * 100.0 / lots.size();
|
||||
if (bidRate > 60) {
|
||||
insights.add(Map.of(
|
||||
"icon", "fa-chart-line",
|
||||
"title", String.format("%.1f%% bid rate", bidRate),
|
||||
"description", "Strong market engagement detected"
|
||||
));
|
||||
}
|
||||
|
||||
long imageCoverage = db.getImageCount() * 100 / Math.max(lots.size(), 1);
|
||||
if (imageCoverage < 80) {
|
||||
insights.add(Map.of(
|
||||
"icon", "fa-images",
|
||||
"title", imageCoverage + "% image coverage",
|
||||
"description", "Consider processing more images"
|
||||
));
|
||||
}
|
||||
|
||||
// Add geographic insight (filter out null countries)
|
||||
String topCountry = auctions.stream()
|
||||
.filter(a -> a.country() != null)
|
||||
.collect(Collectors.groupingBy(AuctionInfo::country, Collectors.counting()))
|
||||
.entrySet().stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse("N/A");
|
||||
|
||||
if (!"N/A".equals(topCountry)) {
|
||||
insights.add(Map.of(
|
||||
"icon", "fa-globe",
|
||||
"title", topCountry + " leading",
|
||||
"description", "Top performing country"
|
||||
));
|
||||
}
|
||||
|
||||
// Add sleeper lots insight
|
||||
long sleeperCount = lots.stream().filter(Lot::isSleeperLot).count();
|
||||
if (sleeperCount > 0) {
|
||||
insights.add(Map.of(
|
||||
"icon", "fa-eye",
|
||||
"title", sleeperCount + " sleeper lots",
|
||||
"description", "High interest, low bids - opportunity?"
|
||||
));
|
||||
}
|
||||
|
||||
// Add bargain insight
|
||||
long bargainCount = lots.stream().filter(Lot::isBelowEstimate).count();
|
||||
if (bargainCount > 5) {
|
||||
insights.add(Map.of(
|
||||
"icon", "fa-tag",
|
||||
"title", bargainCount + " bargains",
|
||||
"description", "Priced below auction house estimates"
|
||||
));
|
||||
}
|
||||
|
||||
// Add watch/followers insight
|
||||
long highWatchCount = lots.stream()
|
||||
.filter(l -> l.followersCount() != null && l.followersCount() > 20)
|
||||
.count();
|
||||
if (highWatchCount > 0) {
|
||||
insights.add(Map.of(
|
||||
"icon", "fa-fire",
|
||||
"title", highWatchCount + " hot lots",
|
||||
"description", "High follower count, strong competition"
|
||||
));
|
||||
}
|
||||
|
||||
return Response.ok(insights).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get insights", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/intelligence/sleepers
|
||||
* Returns "sleeper" lots (high watch count, low bids)
|
||||
*/
|
||||
@GET
|
||||
@Path("/intelligence/sleepers")
|
||||
public Response getSleeperLots(@QueryParam("minFollowers") @DefaultValue("10") int minFollowers) {
|
||||
try {
|
||||
var allLots = db.getAllLots();
|
||||
var sleepers = allLots.stream()
|
||||
.filter(Lot::isSleeperLot)
|
||||
.toList();
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
"count", sleepers.size(),
|
||||
"lots", sleepers
|
||||
);
|
||||
|
||||
return Response.ok(response).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get sleeper lots", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/intelligence/bargains
|
||||
* Returns lots priced below auction house estimates
|
||||
*/
|
||||
@GET
|
||||
@Path("/intelligence/bargains")
|
||||
public Response getBargains() {
|
||||
try {
|
||||
var allLots = db.getAllLots();
|
||||
var bargains = allLots.stream()
|
||||
.filter(Lot::isBelowEstimate)
|
||||
.sorted((a, b) -> {
|
||||
Double ratioA = a.getPriceVsEstimateRatio();
|
||||
Double ratioB = b.getPriceVsEstimateRatio();
|
||||
if (ratioA == null) return 1;
|
||||
if (ratioB == null) return -1;
|
||||
return ratioA.compareTo(ratioB);
|
||||
})
|
||||
.toList();
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
"count", bargains.size(),
|
||||
"lots", bargains
|
||||
);
|
||||
|
||||
return Response.ok(response).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get bargains", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/intelligence/popular
|
||||
* Returns lots by popularity level
|
||||
*/
|
||||
@GET
|
||||
@Path("/intelligence/popular")
|
||||
public Response getPopularLots(@QueryParam("level") @DefaultValue("HIGH") String level) {
|
||||
try {
|
||||
var allLots = db.getAllLots();
|
||||
var popular = allLots.stream()
|
||||
.filter(lot -> level.equalsIgnoreCase(lot.getPopularityLevel()))
|
||||
.sorted((a, b) -> {
|
||||
Integer followersA = a.followersCount() != null ? a.followersCount() : 0;
|
||||
Integer followersB = b.followersCount() != null ? b.followersCount() : 0;
|
||||
return followersB.compareTo(followersA);
|
||||
})
|
||||
.toList();
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
"count", popular.size(),
|
||||
"level", level,
|
||||
"lots", popular
|
||||
);
|
||||
|
||||
return Response.ok(response).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get popular lots", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/intelligence/price-analysis
|
||||
* Returns price vs estimate analysis
|
||||
*/
|
||||
@GET
|
||||
@Path("/intelligence/price-analysis")
|
||||
public Response getPriceAnalysis() {
|
||||
try {
|
||||
var allLots = db.getAllLots();
|
||||
|
||||
long belowEstimate = allLots.stream().filter(Lot::isBelowEstimate).count();
|
||||
long aboveEstimate = allLots.stream().filter(Lot::isAboveEstimate).count();
|
||||
long withEstimates = allLots.stream()
|
||||
.filter(lot -> lot.estimatedMin() != null && lot.estimatedMax() != null)
|
||||
.count();
|
||||
|
||||
double avgPriceVsEstimate = allLots.stream()
|
||||
.map(Lot::getPriceVsEstimateRatio)
|
||||
.filter(ratio -> ratio != null)
|
||||
.mapToDouble(Double::doubleValue)
|
||||
.average()
|
||||
.orElse(0.0);
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
"totalLotsWithEstimates", withEstimates,
|
||||
"belowEstimate", belowEstimate,
|
||||
"aboveEstimate", aboveEstimate,
|
||||
"averagePriceVsEstimatePercent", Math.round(avgPriceVsEstimate),
|
||||
"bargainOpportunities", belowEstimate
|
||||
);
|
||||
|
||||
return Response.ok(response).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get price analysis", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/lots/{lotId}/intelligence
|
||||
* Returns detailed intelligence for a specific lot
|
||||
*/
|
||||
@GET
|
||||
@Path("/lots/{lotId}/intelligence")
|
||||
public Response getLotIntelligence(@PathParam("lotId") long lotId) {
|
||||
try {
|
||||
var lot = db.getAllLots().stream()
|
||||
.filter(l -> l.lotId() == lotId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (lot == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "Lot not found"))
|
||||
.build();
|
||||
}
|
||||
|
||||
Map<String, Object> intelligence = new HashMap<>();
|
||||
intelligence.put("lotId", lot.lotId());
|
||||
intelligence.put("followersCount", lot.followersCount());
|
||||
intelligence.put("popularityLevel", lot.getPopularityLevel());
|
||||
intelligence.put("estimatedMidpoint", lot.getEstimatedMidpoint());
|
||||
intelligence.put("priceVsEstimatePercent", lot.getPriceVsEstimateRatio());
|
||||
intelligence.put("isBargain", lot.isBelowEstimate());
|
||||
intelligence.put("isOvervalued", lot.isAboveEstimate());
|
||||
intelligence.put("isSleeperLot", lot.isSleeperLot());
|
||||
intelligence.put("nextBidAmount", lot.calculateNextBid());
|
||||
intelligence.put("totalCostWithFees", lot.calculateTotalCost());
|
||||
intelligence.put("viewCount", lot.viewCount());
|
||||
intelligence.put("bidVelocity", lot.bidVelocity());
|
||||
intelligence.put("condition", lot.condition());
|
||||
intelligence.put("vat", lot.vat());
|
||||
intelligence.put("buyerPremium", lot.buyerPremiumPercentage());
|
||||
|
||||
return Response.ok(intelligence).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get lot intelligence", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitor/charts/watch-distribution
|
||||
* Returns follower/watch count distribution
|
||||
*/
|
||||
@GET
|
||||
@Path("/charts/watch-distribution")
|
||||
public Response getWatchDistribution() {
|
||||
try {
|
||||
var lots = db.getAllLots();
|
||||
|
||||
Map<String, Long> distribution = new HashMap<>();
|
||||
distribution.put("0 watchers", lots.stream().filter(l -> l.followersCount() == null || l.followersCount() == 0).count());
|
||||
distribution.put("1-5 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 1 && l.followersCount() <= 5).count());
|
||||
distribution.put("6-20 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 6 && l.followersCount() <= 20).count());
|
||||
distribution.put("21-50 watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() >= 21 && l.followersCount() <= 50).count());
|
||||
distribution.put("50+ watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() > 50).count());
|
||||
|
||||
return Response.ok(distribution).build();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to get watch distribution", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class for trend data
|
||||
public static class TrendHour {
|
||||
|
||||
public int hour;
|
||||
public int lots;
|
||||
public int bids;
|
||||
|
||||
public TrendHour(int hour, int lots, int bids) {
|
||||
this.hour = hour;
|
||||
this.lots = lots;
|
||||
this.bids = bids;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
src/main/java/auctiora/BidHistory.java
Normal file
16
src/main/java/auctiora/BidHistory.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package auctiora;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Represents a bid in the bid history
|
||||
*/
|
||||
public record BidHistory(
|
||||
int id,
|
||||
String lotId,
|
||||
double bidAmount,
|
||||
LocalDateTime bidTime,
|
||||
boolean isAutobid,
|
||||
String bidderId,
|
||||
Integer bidderNumber
|
||||
) {}
|
||||
@@ -250,6 +250,9 @@ public class DatabaseService {
|
||||
log.info("Migrating schema: Adding 'closing_notified' column to lots table");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN closing_notified INTEGER DEFAULT 0");
|
||||
}
|
||||
|
||||
// Migrate intelligence fields from GraphQL API
|
||||
migrateIntelligenceFields(stmt);
|
||||
} catch (SQLException e) {
|
||||
// Table might not exist yet, which is fine
|
||||
log.debug("Could not check lots table schema: " + e.getMessage());
|
||||
@@ -294,6 +297,118 @@ public class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates intelligence fields to lots table (GraphQL enrichment data)
|
||||
*/
|
||||
private void migrateIntelligenceFields(java.sql.Statement stmt) throws SQLException {
|
||||
try (var rs = stmt.executeQuery("PRAGMA table_info(lots)")) {
|
||||
var columns = new java.util.HashSet<String>();
|
||||
while (rs.next()) {
|
||||
columns.add(rs.getString("name"));
|
||||
}
|
||||
|
||||
// HIGH PRIORITY FIELDS
|
||||
if (!columns.contains("followers_count")) {
|
||||
log.info("Adding followers_count column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN followers_count INTEGER");
|
||||
}
|
||||
if (!columns.contains("estimated_min")) {
|
||||
log.info("Adding estimated_min column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN estimated_min REAL");
|
||||
}
|
||||
if (!columns.contains("estimated_max")) {
|
||||
log.info("Adding estimated_max column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN estimated_max REAL");
|
||||
}
|
||||
if (!columns.contains("next_bid_step_cents")) {
|
||||
log.info("Adding next_bid_step_cents column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN next_bid_step_cents INTEGER");
|
||||
}
|
||||
if (!columns.contains("condition")) {
|
||||
log.info("Adding condition column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN condition TEXT");
|
||||
}
|
||||
if (!columns.contains("category_path")) {
|
||||
log.info("Adding category_path column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN category_path TEXT");
|
||||
}
|
||||
if (!columns.contains("city_location")) {
|
||||
log.info("Adding city_location column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN city_location TEXT");
|
||||
}
|
||||
if (!columns.contains("country_code")) {
|
||||
log.info("Adding country_code column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN country_code TEXT");
|
||||
}
|
||||
|
||||
// MEDIUM PRIORITY FIELDS
|
||||
if (!columns.contains("bidding_status")) {
|
||||
log.info("Adding bidding_status column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN bidding_status TEXT");
|
||||
}
|
||||
if (!columns.contains("appearance")) {
|
||||
log.info("Adding appearance column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN appearance TEXT");
|
||||
}
|
||||
if (!columns.contains("packaging")) {
|
||||
log.info("Adding packaging column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN packaging TEXT");
|
||||
}
|
||||
if (!columns.contains("quantity")) {
|
||||
log.info("Adding quantity column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN quantity INTEGER");
|
||||
}
|
||||
if (!columns.contains("vat")) {
|
||||
log.info("Adding vat column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN vat REAL");
|
||||
}
|
||||
if (!columns.contains("buyer_premium_percentage")) {
|
||||
log.info("Adding buyer_premium_percentage column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN buyer_premium_percentage REAL");
|
||||
}
|
||||
if (!columns.contains("remarks")) {
|
||||
log.info("Adding remarks column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN remarks TEXT");
|
||||
}
|
||||
|
||||
// BID INTELLIGENCE FIELDS
|
||||
if (!columns.contains("starting_bid")) {
|
||||
log.info("Adding starting_bid column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN starting_bid REAL");
|
||||
}
|
||||
if (!columns.contains("reserve_price")) {
|
||||
log.info("Adding reserve_price column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN reserve_price REAL");
|
||||
}
|
||||
if (!columns.contains("reserve_met")) {
|
||||
log.info("Adding reserve_met column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN reserve_met INTEGER");
|
||||
}
|
||||
if (!columns.contains("bid_increment")) {
|
||||
log.info("Adding bid_increment column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN bid_increment REAL");
|
||||
}
|
||||
if (!columns.contains("view_count")) {
|
||||
log.info("Adding view_count column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN view_count INTEGER");
|
||||
}
|
||||
if (!columns.contains("first_bid_time")) {
|
||||
log.info("Adding first_bid_time column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN first_bid_time TEXT");
|
||||
}
|
||||
if (!columns.contains("last_bid_time")) {
|
||||
log.info("Adding last_bid_time column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN last_bid_time TEXT");
|
||||
}
|
||||
if (!columns.contains("bid_velocity")) {
|
||||
log.info("Adding bid_velocity column");
|
||||
stmt.execute("ALTER TABLE lots ADD COLUMN bid_velocity REAL");
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
log.warn("Could not migrate intelligence fields: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or updates an auction record (typically called by external scraper)
|
||||
*/
|
||||
@@ -544,37 +659,19 @@ public class DatabaseService {
|
||||
*/
|
||||
synchronized List<Lot> getActiveLots() throws SQLException {
|
||||
List<Lot> list = new ArrayList<>();
|
||||
var sql = "SELECT lot_id, sale_id, title, description, manufacturer, type, year, category, " +
|
||||
var sql = "SELECT lot_id, sale_id as auction_id, title, description, manufacturer, type, year, category, " +
|
||||
"current_bid, currency, url, closing_time, closing_notified FROM lots";
|
||||
|
||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
||||
var rs = stmt.executeQuery(sql);
|
||||
while (rs.next()) {
|
||||
var closingStr = rs.getString("closing_time");
|
||||
LocalDateTime closing = null;
|
||||
if (closingStr != null && !closingStr.isBlank()) {
|
||||
try {
|
||||
closing = LocalDateTime.parse(closingStr);
|
||||
} catch (Exception e) {
|
||||
log.debug("Invalid closing_time format for lot {}: {}", rs.getLong("lot_id"), closingStr);
|
||||
}
|
||||
try {
|
||||
// Use ScraperDataAdapter to handle TEXT parsing from legacy database
|
||||
var lot = ScraperDataAdapter.fromScraperLot(rs);
|
||||
list.add(lot);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse lot {}: {}", rs.getString("lot_id"), e.getMessage());
|
||||
}
|
||||
|
||||
list.add(new Lot(
|
||||
rs.getLong("sale_id"),
|
||||
rs.getLong("lot_id"),
|
||||
rs.getString("title"),
|
||||
rs.getString("description"),
|
||||
rs.getString("manufacturer"),
|
||||
rs.getString("type"),
|
||||
rs.getInt("year"),
|
||||
rs.getString("category"),
|
||||
rs.getDouble("current_bid"),
|
||||
rs.getString("currency"),
|
||||
rs.getString("url"),
|
||||
closing,
|
||||
rs.getInt("closing_notified") != 0
|
||||
));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
@@ -624,6 +721,44 @@ public class DatabaseService {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves bid history for a specific lot
|
||||
*/
|
||||
synchronized List<BidHistory> getBidHistory(String lotId) throws SQLException {
|
||||
List<BidHistory> history = new ArrayList<>();
|
||||
var sql = "SELECT id, lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number " +
|
||||
"FROM bid_history WHERE lot_id = ? ORDER BY bid_time DESC LIMIT 100";
|
||||
|
||||
try (var conn = DriverManager.getConnection(url);
|
||||
var ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, lotId);
|
||||
var rs = ps.executeQuery();
|
||||
|
||||
while (rs.next()) {
|
||||
LocalDateTime bidTime = null;
|
||||
var bidTimeStr = rs.getString("bid_time");
|
||||
if (bidTimeStr != null && !bidTimeStr.isBlank()) {
|
||||
try {
|
||||
bidTime = LocalDateTime.parse(bidTimeStr);
|
||||
} catch (Exception e) {
|
||||
log.debug("Invalid bid_time format: {}", bidTimeStr);
|
||||
}
|
||||
}
|
||||
|
||||
history.add(new BidHistory(
|
||||
rs.getInt("id"),
|
||||
rs.getString("lot_id"),
|
||||
rs.getDouble("bid_amount"),
|
||||
bidTime,
|
||||
rs.getInt("is_autobid") != 0,
|
||||
rs.getString("bidder_id"),
|
||||
rs.getInt("bidder_number")
|
||||
));
|
||||
}
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports auctions from scraper's schema format.
|
||||
|
||||
@@ -38,6 +38,20 @@ class ImageProcessingService {
|
||||
*/
|
||||
boolean processImage(int imageId, String localPath, long lotId) {
|
||||
try {
|
||||
// Check if file exists before processing
|
||||
var file = new java.io.File(localPath);
|
||||
if (!file.exists() || !file.canRead()) {
|
||||
log.warn(" Image file not accessible: {}", localPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file size (skip very large files that might cause issues)
|
||||
long fileSizeBytes = file.length();
|
||||
if (fileSizeBytes > 50 * 1024 * 1024) { // 50 MB limit
|
||||
log.warn(" Image file too large ({}MB): {}", fileSizeBytes / (1024 * 1024), localPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Run object detection on the local file
|
||||
var labels = detector.detectObjects(localPath);
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@ import lombok.With;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Represents a lot (kavel) in an auction.
|
||||
* Data typically populated by the external scraper process.
|
||||
* This project enriches the data with image analysis and monitoring.
|
||||
*/
|
||||
/// Represents a lot (kavel) in an auction.
|
||||
/// Data typically populated by the external scraper process.
|
||||
/// This project enriches the data with image analysis and monitoring.
|
||||
@With
|
||||
record Lot(
|
||||
long saleId,
|
||||
@@ -23,23 +21,132 @@ record Lot(
|
||||
String currency,
|
||||
String url,
|
||||
LocalDateTime closingTime,
|
||||
boolean closingNotified
|
||||
boolean closingNotified,
|
||||
|
||||
// HIGH PRIORITY FIELDS from GraphQL API
|
||||
Integer followersCount, // Watch count - direct competition indicator
|
||||
Double estimatedMin, // Auction house min estimate (cents)
|
||||
Double estimatedMax, // Auction house max estimate (cents)
|
||||
Long nextBidStepInCents, // Exact bid increment from API
|
||||
String condition, // Direct condition field
|
||||
String categoryPath, // Structured category (e.g., "Vehicles > Cars > Classic")
|
||||
String cityLocation, // Structured location
|
||||
String countryCode, // ISO country code
|
||||
|
||||
// MEDIUM PRIORITY FIELDS
|
||||
String biddingStatus, // More detailed than minimumBidAmountMet
|
||||
String appearance, // Visual condition notes
|
||||
String packaging, // Packaging details
|
||||
Long quantity, // Lot quantity (bulk lots)
|
||||
Double vat, // VAT percentage
|
||||
Double buyerPremiumPercentage, // Buyer premium
|
||||
String remarks, // Viewing/pickup notes
|
||||
|
||||
// BID INTELLIGENCE FIELDS
|
||||
Double startingBid, // Starting/opening bid
|
||||
Double reservePrice, // Reserve price (if disclosed)
|
||||
Boolean reserveMet, // Reserve met status
|
||||
Double bidIncrement, // Calculated bid increment
|
||||
Integer viewCount, // Number of views
|
||||
LocalDateTime firstBidTime, // First bid timestamp
|
||||
LocalDateTime lastBidTime, // Last bid timestamp
|
||||
Double bidVelocity, // Bids per hour
|
||||
Double condition_score,
|
||||
//Integer manufacturing_year,
|
||||
Integer provenance_docs
|
||||
) {
|
||||
|
||||
public Integer provenanceDocs() { return provenance_docs; }
|
||||
/// manufacturing_year
|
||||
public Integer manufacturingYear() { return year; }
|
||||
public Double conditionScore() { return condition_score; }
|
||||
public long minutesUntilClose() {
|
||||
if (closingTime == null) return Long.MAX_VALUE;
|
||||
return Duration.between(LocalDateTime.now(), closingTime).toMinutes();
|
||||
}
|
||||
public Lot withCurrentBid(double newBid) {
|
||||
return new Lot(saleId, lotId, title, description,
|
||||
manufacturer, type, year, category,
|
||||
newBid, currency, url, closingTime, closingNotified);
|
||||
// Intelligence Methods
|
||||
/// Calculate total cost including VAT and buyer premium
|
||||
public double calculateTotalCost() {
|
||||
double base = currentBid > 0 ? currentBid : 0;
|
||||
if (vat != null && vat > 0) {
|
||||
base += (base * vat / 100.0);
|
||||
}
|
||||
if (buyerPremiumPercentage != null && buyerPremiumPercentage > 0) {
|
||||
base += (base * buyerPremiumPercentage / 100.0);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
public Lot withClosingNotified(boolean flag) {
|
||||
return new Lot(saleId, lotId, title, description,
|
||||
manufacturer, type, year, category,
|
||||
currentBid, currency, url, closingTime, flag);
|
||||
/// Calculate next bid amount using API-provided increment
|
||||
public double calculateNextBid() {
|
||||
if (nextBidStepInCents != null && nextBidStepInCents > 0) {
|
||||
return currentBid + (nextBidStepInCents / 100.0);
|
||||
} else if (bidIncrement != null && bidIncrement > 0) {
|
||||
return currentBid + bidIncrement;
|
||||
}
|
||||
// Fallback: 5% increment
|
||||
return currentBid * 1.05;
|
||||
}
|
||||
|
||||
/// Check if current bid is below estimate (potential bargain)
|
||||
public boolean isBelowEstimate() {
|
||||
if (estimatedMin == null || estimatedMin == 0) return false;
|
||||
return currentBid < (estimatedMin / 100.0);
|
||||
}
|
||||
|
||||
/// Check if current bid exceeds estimate (overvalued)
|
||||
public boolean isAboveEstimate() {
|
||||
if (estimatedMax == null || estimatedMax == 0) return false;
|
||||
return currentBid > (estimatedMax / 100.0);
|
||||
}
|
||||
|
||||
/// Calculate interest-to-bid conversion rate
|
||||
public double getInterestToBidRatio() {
|
||||
if (followersCount == null || followersCount == 0) return 0.0;
|
||||
return currentBid > 0 ? 100.0 : 0.0;
|
||||
}
|
||||
|
||||
/// Determine lot popularity level
|
||||
public String getPopularityLevel() {
|
||||
if (followersCount == null) return "UNKNOWN";
|
||||
if (followersCount > 50) return "HIGH";
|
||||
if (followersCount > 20) return "MEDIUM";
|
||||
if (followersCount > 5) return "LOW";
|
||||
return "MINIMAL";
|
||||
}
|
||||
|
||||
/// Check if lot is a "sleeper" (high interest, low bids)
|
||||
public boolean isSleeperLot() {
|
||||
return followersCount != null && followersCount > 10 && currentBid < 100;
|
||||
}
|
||||
|
||||
/// Calculate estimated value range midpoint
|
||||
public Double getEstimatedMidpoint() {
|
||||
if (estimatedMin == null || estimatedMax == null) return null;
|
||||
return (estimatedMin + estimatedMax) / 200.0; // Convert from cents
|
||||
}
|
||||
|
||||
/// Calculate price vs estimate ratio (for analytics)
|
||||
public Double getPriceVsEstimateRatio() {
|
||||
Double midpoint = getEstimatedMidpoint();
|
||||
if (midpoint == null || midpoint == 0 || currentBid == 0) return null;
|
||||
return (currentBid / midpoint) * 100.0;
|
||||
}
|
||||
|
||||
/// Factory method for creating a basic Lot without intelligence fields (for tests and backward compatibility)
|
||||
public static Lot basic(
|
||||
long saleId, long lotId, String title, String description,
|
||||
String manufacturer, String type, int year, String category,
|
||||
double currentBid, String currency, String url,
|
||||
LocalDateTime closingTime, boolean closingNotified) {
|
||||
return new Lot(
|
||||
saleId, lotId, title, description, manufacturer, type, year, category,
|
||||
currentBid, currency, url, closingTime, closingNotified,
|
||||
null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null,
|
||||
null, null
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
88
src/main/java/auctiora/LotEnrichmentScheduler.java
Normal file
88
src/main/java/auctiora/LotEnrichmentScheduler.java
Normal file
@@ -0,0 +1,88 @@
|
||||
package auctiora;
|
||||
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Scheduled tasks for enriching lots with GraphQL intelligence data.
|
||||
* Uses dynamic frequencies based on lot closing times:
|
||||
* - Critical (< 1 hour): Every 5 minutes
|
||||
* - Urgent (< 6 hours): Every 30 minutes
|
||||
* - Normal (< 24 hours): Every 2 hours
|
||||
* - All lots: Every 6 hours
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class LotEnrichmentScheduler {
|
||||
|
||||
@Inject
|
||||
LotEnrichmentService enrichmentService;
|
||||
|
||||
/**
|
||||
* Enriches lots closing within 1 hour - HIGH PRIORITY
|
||||
* Runs every 5 minutes
|
||||
*/
|
||||
@Scheduled(cron = "0 */5 * * * ?")
|
||||
public void enrichCriticalLots() {
|
||||
try {
|
||||
log.debug("Enriching critical lots (closing < 1 hour)");
|
||||
int enriched = enrichmentService.enrichClosingSoonLots(1);
|
||||
if (enriched > 0) {
|
||||
log.info("Enriched {} critical lots", enriched);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enrich critical lots", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches lots closing within 6 hours - MEDIUM PRIORITY
|
||||
* Runs every 30 minutes
|
||||
*/
|
||||
@Scheduled(cron = "0 */30 * * * ?")
|
||||
public void enrichUrgentLots() {
|
||||
try {
|
||||
log.debug("Enriching urgent lots (closing < 6 hours)");
|
||||
int enriched = enrichmentService.enrichClosingSoonLots(6);
|
||||
if (enriched > 0) {
|
||||
log.info("Enriched {} urgent lots", enriched);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enrich urgent lots", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches lots closing within 24 hours - NORMAL PRIORITY
|
||||
* Runs every 2 hours
|
||||
*/
|
||||
@Scheduled(cron = "0 0 */2 * * ?")
|
||||
public void enrichDailyLots() {
|
||||
try {
|
||||
log.debug("Enriching daily lots (closing < 24 hours)");
|
||||
int enriched = enrichmentService.enrichClosingSoonLots(24);
|
||||
if (enriched > 0) {
|
||||
log.info("Enriched {} daily lots", enriched);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enrich daily lots", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches all active lots - LOW PRIORITY
|
||||
* Runs every 6 hours to keep all data fresh
|
||||
*/
|
||||
@Scheduled(cron = "0 0 */6 * * ?")
|
||||
public void enrichAllLots() {
|
||||
try {
|
||||
log.info("Starting full enrichment of all lots");
|
||||
int enriched = enrichmentService.enrichAllActiveLots();
|
||||
log.info("Full enrichment complete: {} lots updated", enriched);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enrich all lots", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
213
src/main/java/auctiora/LotEnrichmentService.java
Normal file
213
src/main/java/auctiora/LotEnrichmentService.java
Normal file
@@ -0,0 +1,213 @@
|
||||
package auctiora;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service for enriching lots with intelligence data from GraphQL API.
|
||||
* Updates existing lot records with followers, estimates, velocity, etc.
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class LotEnrichmentService {
|
||||
|
||||
@Inject
|
||||
TroostwijkGraphQLClient graphQLClient;
|
||||
|
||||
@Inject
|
||||
DatabaseService db;
|
||||
|
||||
/**
|
||||
* Enriches a single lot with GraphQL intelligence data
|
||||
*/
|
||||
public boolean enrichLot(Lot lot) {
|
||||
try {
|
||||
var intelligence = graphQLClient.fetchLotIntelligence(lot.lotId());
|
||||
if (intelligence == null) {
|
||||
log.debug("No intelligence data for lot {}", lot.lotId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Merge intelligence with existing lot data
|
||||
var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
|
||||
db.upsertLot(enrichedLot);
|
||||
|
||||
log.debug("Enriched lot {} with GraphQL data", lot.lotId());
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to enrich lot {}: {}", lot.lotId(), e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches multiple lots in batch (more efficient)
|
||||
* @param lots List of lots to enrich
|
||||
* @return Number of successfully enriched lots
|
||||
*/
|
||||
public int enrichLotsBatch(List<Lot> lots) {
|
||||
if (lots.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
List<Long> lotIds = lots.stream()
|
||||
.map(Lot::lotId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info("Fetching intelligence for {} lots via GraphQL batch query", lotIds.size());
|
||||
var intelligenceList = graphQLClient.fetchBatchLotIntelligence(lotIds);
|
||||
|
||||
if (intelligenceList.isEmpty()) {
|
||||
log.warn("No intelligence data returned for batch of {} lots", lotIds.size());
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Create map for fast lookup
|
||||
var intelligenceMap = intelligenceList.stream()
|
||||
.collect(Collectors.toMap(
|
||||
LotIntelligence::lotId,
|
||||
intel -> intel
|
||||
));
|
||||
|
||||
int enrichedCount = 0;
|
||||
for (var lot : lots) {
|
||||
var intelligence = intelligenceMap.get(lot.lotId());
|
||||
if (intelligence != null) {
|
||||
try {
|
||||
var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
|
||||
db.upsertLot(enrichedLot);
|
||||
enrichedCount++;
|
||||
} catch (SQLException e) {
|
||||
log.warn("Failed to update lot {}: {}", lot.lotId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Successfully enriched {}/{} lots", enrichedCount, lots.size());
|
||||
return enrichedCount;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enrich lots batch: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches lots closing soon (within specified hours) with higher priority
|
||||
*/
|
||||
public int enrichClosingSoonLots(int hoursUntilClose) {
|
||||
try {
|
||||
var allLots = db.getAllLots();
|
||||
var closingSoon = allLots.stream()
|
||||
.filter(lot -> lot.closingTime() != null)
|
||||
.filter(lot -> {
|
||||
long minutes = lot.minutesUntilClose();
|
||||
return minutes > 0 && minutes <= hoursUntilClose * 60;
|
||||
})
|
||||
.toList();
|
||||
|
||||
if (closingSoon.isEmpty()) {
|
||||
log.debug("No lots closing within {} hours", hoursUntilClose);
|
||||
return 0;
|
||||
}
|
||||
|
||||
log.info("Enriching {} lots closing within {} hours", closingSoon.size(), hoursUntilClose);
|
||||
return enrichLotsBatch(closingSoon);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enrich closing soon lots: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches all active lots (can be slow for large datasets)
|
||||
*/
|
||||
public int enrichAllActiveLots() {
|
||||
try {
|
||||
var allLots = db.getAllLots();
|
||||
log.info("Enriching all {} active lots", allLots.size());
|
||||
|
||||
// Process in batches to avoid overwhelming the API
|
||||
int batchSize = 50;
|
||||
int totalEnriched = 0;
|
||||
|
||||
for (int i = 0; i < allLots.size(); i += batchSize) {
|
||||
int end = Math.min(i + batchSize, allLots.size());
|
||||
List<Lot> batch = allLots.subList(i, end);
|
||||
|
||||
int enriched = enrichLotsBatch(batch);
|
||||
totalEnriched += enriched;
|
||||
|
||||
// Small delay between batches to respect rate limits
|
||||
if (end < allLots.size()) {
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Finished enriching all lots. Total enriched: {}/{}", totalEnriched, allLots.size());
|
||||
return totalEnriched;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enrich all lots: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges existing lot data with GraphQL intelligence
|
||||
*/
|
||||
private Lot mergeLotWithIntelligence(Lot lot, LotIntelligence intel) {
|
||||
return new Lot(
|
||||
lot.saleId(),
|
||||
lot.lotId(),
|
||||
lot.title(),
|
||||
lot.description(),
|
||||
lot.manufacturer(),
|
||||
lot.type(),
|
||||
lot.year(),
|
||||
lot.category(),
|
||||
lot.currentBid(),
|
||||
lot.currency(),
|
||||
lot.url(),
|
||||
lot.closingTime(),
|
||||
lot.closingNotified(),
|
||||
// HIGH PRIORITY FIELDS from GraphQL
|
||||
intel.followersCount(),
|
||||
intel.estimatedMin(),
|
||||
intel.estimatedMax(),
|
||||
intel.nextBidStepInCents(),
|
||||
intel.condition(),
|
||||
intel.categoryPath(),
|
||||
intel.cityLocation(),
|
||||
intel.countryCode(),
|
||||
// MEDIUM PRIORITY FIELDS
|
||||
intel.biddingStatus(),
|
||||
intel.appearance(),
|
||||
intel.packaging(),
|
||||
intel.quantity(),
|
||||
intel.vat(),
|
||||
intel.buyerPremiumPercentage(),
|
||||
intel.remarks(),
|
||||
// BID INTELLIGENCE FIELDS
|
||||
intel.startingBid(),
|
||||
intel.reservePrice(),
|
||||
intel.reserveMet(),
|
||||
intel.bidIncrement(),
|
||||
intel.viewCount(),
|
||||
intel.firstBidTime(),
|
||||
intel.lastBidTime(),
|
||||
intel.bidVelocity(),
|
||||
null, // condition_score (computed separately)
|
||||
null // provenance_docs (computed separately)
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/main/java/auctiora/LotIntelligence.java
Normal file
33
src/main/java/auctiora/LotIntelligence.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package auctiora;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Record holding enriched intelligence data fetched from GraphQL API
|
||||
*/
|
||||
public record LotIntelligence(
|
||||
long lotId,
|
||||
Integer followersCount,
|
||||
Double estimatedMin,
|
||||
Double estimatedMax,
|
||||
Long nextBidStepInCents,
|
||||
String condition,
|
||||
String categoryPath,
|
||||
String cityLocation,
|
||||
String countryCode,
|
||||
String biddingStatus,
|
||||
String appearance,
|
||||
String packaging,
|
||||
Long quantity,
|
||||
Double vat,
|
||||
Double buyerPremiumPercentage,
|
||||
String remarks,
|
||||
Double startingBid,
|
||||
Double reservePrice,
|
||||
Boolean reserveMet,
|
||||
Double bidIncrement,
|
||||
Integer viewCount,
|
||||
LocalDateTime firstBidTime,
|
||||
LocalDateTime lastBidTime,
|
||||
Double bidVelocity
|
||||
) {}
|
||||
@@ -37,6 +37,8 @@ public class ObjectDetectionService {
|
||||
private final Net net;
|
||||
private final List<String> classNames;
|
||||
private final boolean enabled;
|
||||
private int warnCount = 0;
|
||||
private static final int MAX_WARNINGS = 5;
|
||||
|
||||
ObjectDetectionService(String cfgPath, String weightsPath, String classNamesPath) throws IOException {
|
||||
// Check if model files exist
|
||||
@@ -60,12 +62,29 @@ public class ObjectDetectionService {
|
||||
try {
|
||||
// Load network
|
||||
this.net = Dnn.readNetFromDarknet(cfgPath, weightsPath);
|
||||
this.net.setPreferableBackend(DNN_BACKEND_OPENCV);
|
||||
this.net.setPreferableTarget(DNN_TARGET_CPU);
|
||||
|
||||
// Try to use GPU/CUDA if available, fallback to CPU
|
||||
try {
|
||||
this.net.setPreferableBackend(Dnn.DNN_BACKEND_CUDA);
|
||||
this.net.setPreferableTarget(Dnn.DNN_TARGET_CUDA);
|
||||
log.info("✓ Object detection enabled with YOLO (CUDA/GPU acceleration)");
|
||||
} catch (Exception e) {
|
||||
// CUDA not available, try Vulkan for AMD GPUs
|
||||
try {
|
||||
this.net.setPreferableBackend(Dnn.DNN_BACKEND_VKCOM);
|
||||
this.net.setPreferableTarget(Dnn.DNN_TARGET_VULKAN);
|
||||
log.info("✓ Object detection enabled with YOLO (Vulkan/GPU acceleration)");
|
||||
} catch (Exception e2) {
|
||||
// GPU not available, fallback to CPU
|
||||
this.net.setPreferableBackend(DNN_BACKEND_OPENCV);
|
||||
this.net.setPreferableTarget(DNN_TARGET_CPU);
|
||||
log.info("✓ Object detection enabled with YOLO (CPU only)");
|
||||
}
|
||||
}
|
||||
|
||||
// Load class names (one per line)
|
||||
this.classNames = Files.readAllLines(classNamesFile);
|
||||
this.enabled = true;
|
||||
log.info("✓ Object detection enabled with YOLO");
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
System.err.println("⚠️ Object detection disabled: OpenCV native libraries not loaded");
|
||||
throw new IOException("Failed to initialize object detection: OpenCV native libraries not loaded", e);
|
||||
@@ -101,14 +120,44 @@ public class ObjectDetectionService {
|
||||
// Post‑process: for each detection compute score and choose class
|
||||
var confThreshold = 0.5f;
|
||||
for (var out : outs) {
|
||||
for (var i = 0; i < out.rows(); i++) {
|
||||
var data = out.get(i, 0);
|
||||
if (data == null) continue;
|
||||
// The first 5 numbers are bounding box, then class scores
|
||||
// YOLO output shape: [num_detections, 85] where 85 = 4 (bbox) + 1 (objectness) + 80 (classes)
|
||||
int numDetections = out.rows();
|
||||
int numElements = out.cols();
|
||||
int expectedLength = 5 + classNames.size();
|
||||
|
||||
if (numElements < expectedLength) {
|
||||
// Rate-limit warnings to prevent thread blocking from excessive logging
|
||||
if (warnCount < MAX_WARNINGS) {
|
||||
log.warn("Output matrix has wrong dimensions: expected {} columns, got {}. Output shape: [{}, {}]",
|
||||
expectedLength, numElements, numDetections, numElements);
|
||||
warnCount++;
|
||||
if (warnCount == MAX_WARNINGS) {
|
||||
log.warn("Suppressing further dimension warnings (reached {} warnings)", MAX_WARNINGS);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < numDetections; i++) {
|
||||
// Get entire row (all 85 elements)
|
||||
var data = new double[numElements];
|
||||
for (int j = 0; j < numElements; j++) {
|
||||
data[j] = out.get(i, j)[0];
|
||||
}
|
||||
|
||||
// Extract objectness score (index 4) and class scores (index 5+)
|
||||
double objectness = data[4];
|
||||
if (objectness < confThreshold) {
|
||||
continue; // Skip low-confidence detections
|
||||
}
|
||||
|
||||
// Extract class scores
|
||||
var scores = new double[classNames.size()];
|
||||
System.arraycopy(data, 5, scores, 0, scores.length);
|
||||
System.arraycopy(data, 5, scores, 0, Math.min(scores.length, data.length - 5));
|
||||
|
||||
var classId = argMax(scores);
|
||||
var confidence = scores[classId];
|
||||
var confidence = scores[classId] * objectness; // Combine objectness with class confidence
|
||||
|
||||
if (confidence > confThreshold) {
|
||||
var label = classNames.get(classId);
|
||||
if (!labels.contains(label)) {
|
||||
@@ -117,6 +166,13 @@ public class ObjectDetectionService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release resources
|
||||
image.release();
|
||||
blob.release();
|
||||
for (var out : outs) {
|
||||
out.release();
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -94,10 +94,20 @@ public class QuarkusWorkflowScheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.infof(" → Processing %d images", pendingImages.size());
|
||||
// Limit batch size to prevent thread blocking (max 100 images per run)
|
||||
final int MAX_BATCH_SIZE = 100;
|
||||
int totalPending = pendingImages.size();
|
||||
if (totalPending > MAX_BATCH_SIZE) {
|
||||
LOG.infof(" → Found %d pending images, processing first %d (batch limit)",
|
||||
totalPending, MAX_BATCH_SIZE);
|
||||
pendingImages = pendingImages.subList(0, MAX_BATCH_SIZE);
|
||||
} else {
|
||||
LOG.infof(" → Processing %d images", totalPending);
|
||||
}
|
||||
|
||||
var processed = 0;
|
||||
var detected = 0;
|
||||
var failed = 0;
|
||||
|
||||
for (var image : pendingImages) {
|
||||
try {
|
||||
@@ -121,19 +131,26 @@ public class QuarkusWorkflowScheduler {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Rate limiting (lighter since no network I/O)
|
||||
Thread.sleep(100);
|
||||
|
||||
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
LOG.warnf(" ⚠️ Failed to process image: %s", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
var duration = System.currentTimeMillis() - start;
|
||||
LOG.infof(" ✓ Processed %d images, detected objects in %d (%.1fs)",
|
||||
processed, detected, duration / 1000.0);
|
||||
LOG.infof(" ✓ Processed %d/%d images, detected objects in %d, failed %d (%.1fs)",
|
||||
processed, totalPending, detected, failed, duration / 1000.0);
|
||||
|
||||
if (totalPending > MAX_BATCH_SIZE) {
|
||||
LOG.infof(" → %d images remaining for next run", totalPending - MAX_BATCH_SIZE);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
LOG.errorf(e, " ❌ Image processing failed: %s", e.getMessage());
|
||||
@@ -193,7 +210,7 @@ public class QuarkusWorkflowScheduler {
|
||||
notifier.sendNotification(message, "Lot Closing Soon", 1);
|
||||
|
||||
// Mark as notified
|
||||
var updated = new Lot(
|
||||
var updated = Lot.basic(
|
||||
lot.saleId(), lot.lotId(), lot.title(), lot.description(),
|
||||
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
|
||||
lot.currentBid(), lot.currency(), lot.url(),
|
||||
|
||||
@@ -67,7 +67,12 @@ public class ScraperDataAdapter {
|
||||
currency,
|
||||
rs.getString("url"),
|
||||
closing,
|
||||
false
|
||||
false,
|
||||
// New intelligence fields - set to null for now
|
||||
null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null,
|
||||
null, null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
332
src/main/java/auctiora/TroostwijkGraphQLClient.java
Normal file
332
src/main/java/auctiora/TroostwijkGraphQLClient.java
Normal file
@@ -0,0 +1,332 @@
|
||||
package auctiora;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* GraphQL client for fetching enriched lot data from Troostwijk API.
|
||||
* Fetches intelligence fields: followers, estimates, bid velocity, condition, etc.
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class TroostwijkGraphQLClient {
|
||||
|
||||
private static final String GRAPHQL_ENDPOINT = "https://www.troostwijk.com/graphql";
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Inject
|
||||
RateLimitedHttpClient rateLimitedClient;
|
||||
|
||||
/**
|
||||
* Fetches enriched lot data from GraphQL API
|
||||
* @param lotId The lot ID to fetch
|
||||
* @return LotIntelligence with enriched fields, or null if failed
|
||||
*/
|
||||
public LotIntelligence fetchLotIntelligence(long lotId) {
|
||||
try {
|
||||
String query = buildLotQuery(lotId);
|
||||
String requestBody = String.format("{\"query\":\"%s\"}",
|
||||
escapeJson(query));
|
||||
|
||||
var request = java.net.http.HttpRequest.newBuilder()
|
||||
.uri(java.net.URI.create(GRAPHQL_ENDPOINT))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(requestBody))
|
||||
.build();
|
||||
|
||||
var response = rateLimitedClient.send(
|
||||
request,
|
||||
java.net.http.HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
|
||||
if (response == null || response.body() == null) {
|
||||
log.debug("No response from GraphQL for lot {}", lotId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseLotIntelligence(response.body(), lotId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch lot intelligence for {}: {}", lotId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetch multiple lots in a single query (more efficient)
|
||||
*/
|
||||
public List<LotIntelligence> fetchBatchLotIntelligence(List<Long> lotIds) {
|
||||
List<LotIntelligence> results = new ArrayList<>();
|
||||
|
||||
// Split into batches of 50 to avoid query size limits
|
||||
int batchSize = 50;
|
||||
for (int i = 0; i < lotIds.size(); i += batchSize) {
|
||||
int end = Math.min(i + batchSize, lotIds.size());
|
||||
List<Long> batch = lotIds.subList(i, end);
|
||||
|
||||
try {
|
||||
String query = buildBatchLotQuery(batch);
|
||||
String requestBody = String.format("{\"query\":\"%s\"}",
|
||||
escapeJson(query));
|
||||
|
||||
var request = java.net.http.HttpRequest.newBuilder()
|
||||
.uri(java.net.URI.create(GRAPHQL_ENDPOINT))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(requestBody))
|
||||
.build();
|
||||
|
||||
var response = rateLimitedClient.send(
|
||||
request,
|
||||
java.net.http.HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
|
||||
if (response != null && response.body() != null) {
|
||||
results.addAll(parseBatchLotIntelligence(response.body(), batch));
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch batch lot intelligence: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private String buildLotQuery(long lotId) {
|
||||
return """
|
||||
query {
|
||||
lot(id: %d) {
|
||||
id
|
||||
followersCount
|
||||
estimatedMin
|
||||
estimatedMax
|
||||
nextBidStepInCents
|
||||
condition
|
||||
categoryPath
|
||||
city
|
||||
countryCode
|
||||
biddingStatus
|
||||
appearance
|
||||
packaging
|
||||
quantity
|
||||
vat
|
||||
buyerPremiumPercentage
|
||||
remarks
|
||||
startingBid
|
||||
reservePrice
|
||||
reserveMet
|
||||
bidIncrement
|
||||
viewCount
|
||||
firstBidTime
|
||||
lastBidTime
|
||||
bidsCount
|
||||
}
|
||||
}
|
||||
""".formatted(lotId).replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private String buildBatchLotQuery(List<Long> lotIds) {
|
||||
StringBuilder query = new StringBuilder("query {");
|
||||
|
||||
for (int i = 0; i < lotIds.size(); i++) {
|
||||
query.append(String.format("""
|
||||
lot%d: lot(id: %d) {
|
||||
id
|
||||
followersCount
|
||||
estimatedMin
|
||||
estimatedMax
|
||||
nextBidStepInCents
|
||||
condition
|
||||
categoryPath
|
||||
city
|
||||
countryCode
|
||||
biddingStatus
|
||||
vat
|
||||
buyerPremiumPercentage
|
||||
viewCount
|
||||
bidsCount
|
||||
}
|
||||
""", i, lotIds.get(i)));
|
||||
}
|
||||
|
||||
query.append("}");
|
||||
return query.toString().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private LotIntelligence parseLotIntelligence(String json, long lotId) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
JsonNode lotNode = root.path("data").path("lot");
|
||||
|
||||
if (lotNode.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LotIntelligence(
|
||||
lotId,
|
||||
getIntOrNull(lotNode, "followersCount"),
|
||||
getDoubleOrNull(lotNode, "estimatedMin"),
|
||||
getDoubleOrNull(lotNode, "estimatedMax"),
|
||||
getLongOrNull(lotNode, "nextBidStepInCents"),
|
||||
getStringOrNull(lotNode, "condition"),
|
||||
getStringOrNull(lotNode, "categoryPath"),
|
||||
getStringOrNull(lotNode, "city"),
|
||||
getStringOrNull(lotNode, "countryCode"),
|
||||
getStringOrNull(lotNode, "biddingStatus"),
|
||||
getStringOrNull(lotNode, "appearance"),
|
||||
getStringOrNull(lotNode, "packaging"),
|
||||
getLongOrNull(lotNode, "quantity"),
|
||||
getDoubleOrNull(lotNode, "vat"),
|
||||
getDoubleOrNull(lotNode, "buyerPremiumPercentage"),
|
||||
getStringOrNull(lotNode, "remarks"),
|
||||
getDoubleOrNull(lotNode, "startingBid"),
|
||||
getDoubleOrNull(lotNode, "reservePrice"),
|
||||
getBooleanOrNull(lotNode, "reserveMet"),
|
||||
getDoubleOrNull(lotNode, "bidIncrement"),
|
||||
getIntOrNull(lotNode, "viewCount"),
|
||||
parseDateTime(getStringOrNull(lotNode, "firstBidTime")),
|
||||
parseDateTime(getStringOrNull(lotNode, "lastBidTime")),
|
||||
calculateBidVelocity(lotNode)
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse lot intelligence: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<LotIntelligence> parseBatchLotIntelligence(String json, List<Long> lotIds) {
|
||||
List<LotIntelligence> results = new ArrayList<>();
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
JsonNode data = root.path("data");
|
||||
|
||||
for (int i = 0; i < lotIds.size(); i++) {
|
||||
JsonNode lotNode = data.path("lot" + i);
|
||||
if (!lotNode.isMissingNode()) {
|
||||
var intelligence = parseLotIntelligenceFromNode(lotNode, lotIds.get(i));
|
||||
if (intelligence != null) {
|
||||
results.add(intelligence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse batch lot intelligence: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private LotIntelligence parseLotIntelligenceFromNode(JsonNode lotNode, long lotId) {
|
||||
try {
|
||||
return new LotIntelligence(
|
||||
lotId,
|
||||
getIntOrNull(lotNode, "followersCount"),
|
||||
getDoubleOrNull(lotNode, "estimatedMin"),
|
||||
getDoubleOrNull(lotNode, "estimatedMax"),
|
||||
getLongOrNull(lotNode, "nextBidStepInCents"),
|
||||
getStringOrNull(lotNode, "condition"),
|
||||
getStringOrNull(lotNode, "categoryPath"),
|
||||
getStringOrNull(lotNode, "city"),
|
||||
getStringOrNull(lotNode, "countryCode"),
|
||||
getStringOrNull(lotNode, "biddingStatus"),
|
||||
null, // appearance not in batch query
|
||||
null, // packaging not in batch query
|
||||
null, // quantity not in batch query
|
||||
getDoubleOrNull(lotNode, "vat"),
|
||||
getDoubleOrNull(lotNode, "buyerPremiumPercentage"),
|
||||
null, // remarks not in batch query
|
||||
null, // startingBid not in batch query
|
||||
null, // reservePrice not in batch query
|
||||
null, // reserveMet not in batch query
|
||||
null, // bidIncrement not in batch query
|
||||
getIntOrNull(lotNode, "viewCount"),
|
||||
null, // firstBidTime not in batch query
|
||||
null, // lastBidTime not in batch query
|
||||
calculateBidVelocity(lotNode)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse lot node: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Double calculateBidVelocity(JsonNode lotNode) {
|
||||
try {
|
||||
Integer bidsCount = getIntOrNull(lotNode, "bidsCount");
|
||||
String firstBidStr = getStringOrNull(lotNode, "firstBidTime");
|
||||
|
||||
if (bidsCount == null || firstBidStr == null || bidsCount == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LocalDateTime firstBid = parseDateTime(firstBidStr);
|
||||
if (firstBid == null) return null;
|
||||
|
||||
long hoursElapsed = java.time.Duration.between(firstBid, LocalDateTime.now()).toHours();
|
||||
if (hoursElapsed == 0) return (double) bidsCount;
|
||||
|
||||
return (double) bidsCount / hoursElapsed;
|
||||
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime parseDateTime(String dateStr) {
|
||||
if (dateStr == null || dateStr.isBlank()) return null;
|
||||
|
||||
try {
|
||||
return LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_DATE_TIME);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String escapeJson(String str) {
|
||||
return str.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
|
||||
private Integer getIntOrNull(JsonNode node, String field) {
|
||||
JsonNode fieldNode = node.path(field);
|
||||
return fieldNode.isNumber() ? fieldNode.asInt() : null;
|
||||
}
|
||||
|
||||
private Long getLongOrNull(JsonNode node, String field) {
|
||||
JsonNode fieldNode = node.path(field);
|
||||
return fieldNode.isNumber() ? fieldNode.asLong() : null;
|
||||
}
|
||||
|
||||
private Double getDoubleOrNull(JsonNode node, String field) {
|
||||
JsonNode fieldNode = node.path(field);
|
||||
return fieldNode.isNumber() ? fieldNode.asDouble() : null;
|
||||
}
|
||||
|
||||
private String getStringOrNull(JsonNode node, String field) {
|
||||
JsonNode fieldNode = node.path(field);
|
||||
return fieldNode.isTextual() ? fieldNode.asText() : null;
|
||||
}
|
||||
|
||||
private Boolean getBooleanOrNull(JsonNode node, String field) {
|
||||
JsonNode fieldNode = node.path(field);
|
||||
return fieldNode.isBoolean() ? fieldNode.asBoolean() : null;
|
||||
}
|
||||
}
|
||||
378
src/main/java/auctiora/ValuationAnalyticsResource.java
Normal file
378
src/main/java/auctiora/ValuationAnalyticsResource.java
Normal file
@@ -0,0 +1,378 @@
|
||||
package auctiora;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST API for Auction Valuation Analytics
|
||||
* Implements the mathematical framework for fair market value calculation,
|
||||
* undervaluation detection, and bidding strategy recommendations.
|
||||
*/
|
||||
@Path("/api/analytics")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class ValuationAnalyticsResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ValuationAnalyticsResource.class);
|
||||
|
||||
@Inject
|
||||
DatabaseService db;
|
||||
|
||||
/**
|
||||
* GET /api/analytics/valuation/health
|
||||
* Health check endpoint to verify API availability
|
||||
*/
|
||||
@GET
|
||||
@Path("/valuation/health")
|
||||
public Response healthCheck() {
|
||||
return Response.ok(Map.of(
|
||||
"status", "healthy",
|
||||
"service", "valuation-analytics",
|
||||
"timestamp", java.time.LocalDateTime.now().toString()
|
||||
)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/analytics/valuation
|
||||
* Main valuation endpoint that calculates FMV, undervaluation score,
|
||||
* predicted final price, and bidding strategy
|
||||
*/
|
||||
@POST
|
||||
@Path("/valuation")
|
||||
public Response calculateValuation(ValuationRequest request) {
|
||||
try {
|
||||
LOG.infof("Valuation request for lot: %s", request.lotId);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// Step 1: Fetch comparable sales from database
|
||||
List<ComparableLot> comparables = fetchComparables(request);
|
||||
|
||||
// Step 2: Calculate Fair Market Value (FMV)
|
||||
FairMarketValue fmv = calculateFairMarketValue(request, comparables);
|
||||
|
||||
// Step 3: Calculate undervaluation score
|
||||
double undervaluationScore = calculateUndervaluationScore(request, fmv.value);
|
||||
|
||||
// Step 4: Predict final price
|
||||
PricePrediction prediction = calculateFinalPrice(request, fmv.value);
|
||||
|
||||
// Step 5: Generate bidding strategy
|
||||
BiddingStrategy strategy = generateBiddingStrategy(request, fmv, prediction);
|
||||
|
||||
// Step 6: Compile response
|
||||
ValuationResponse response = new ValuationResponse();
|
||||
response.lotId = request.lotId;
|
||||
response.timestamp = LocalDateTime.now().toString();
|
||||
response.fairMarketValue = fmv;
|
||||
response.undervaluationScore = undervaluationScore;
|
||||
response.pricePrediction = prediction;
|
||||
response.biddingStrategy = strategy;
|
||||
response.parameters = request;
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
LOG.infof("Valuation completed in %d ms", duration);
|
||||
|
||||
return Response.ok(response).build();
|
||||
|
||||
} catch (Exception e) {
|
||||
LOG.error("Valuation calculation failed", e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches comparable lots from database based on category, manufacturer,
|
||||
* year, and condition similarity
|
||||
*/
|
||||
private List<ComparableLot> fetchComparables(ValuationRequest req) {
|
||||
// TODO: Replace with actual database query
|
||||
// For now, return mock data simulating real comparables
|
||||
|
||||
return List.of(
|
||||
new ComparableLot("NL-2023-4451", 8200.0, 8.0, 2016, 1, 30),
|
||||
new ComparableLot("BE-2023-9823", 7800.0, 7.0, 2014, 0, 45),
|
||||
new ComparableLot("DE-2024-1234", 8500.0, 9.0, 2017, 1, 60),
|
||||
new ComparableLot("NL-2023-5678", 7500.0, 6.0, 2013, 0, 25),
|
||||
new ComparableLot("BE-2024-7890", 7900.0, 7.5, 2015, 1, 15),
|
||||
new ComparableLot("NL-2023-2345", 8100.0, 8.5, 2016, 0, 40),
|
||||
new ComparableLot("DE-2024-4567", 8300.0, 7.0, 2015, 1, 55),
|
||||
new ComparableLot("BE-2023-3456", 7700.0, 6.5, 2014, 0, 35)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formula: FMV = Σ(P_i · ω_c · ω_t · ω_p · ω_h) / Σ(ω_c · ω_t · ω_p · ω_h)
|
||||
* Where weights are exponential/logistic functions of similarity
|
||||
*/
|
||||
private FairMarketValue calculateFairMarketValue(ValuationRequest req, List<ComparableLot> comparables) {
|
||||
double weightedSum = 0.0;
|
||||
double weightSum = 0.0;
|
||||
List<WeightedComparable> weightedComps = new ArrayList<>();
|
||||
|
||||
for (ComparableLot comp : comparables) {
|
||||
// Condition weight: ω_c = exp(-λ_c · |C_target - C_i|)
|
||||
double omegaC = Math.exp(-0.693 * Math.abs(req.conditionScore - comp.conditionScore));
|
||||
|
||||
// Time weight: ω_t = exp(-λ_t · |T_target - T_i|)
|
||||
double omegaT = Math.exp(-0.048 * Math.abs(req.manufacturingYear - comp.manufacturingYear));
|
||||
|
||||
// Provenance weight: ω_p = 1 + δ_p · (P_target - P_i)
|
||||
double omegaP = 1 + 0.15 * ((req.provenanceDocs > 0 ? 1 : 0) - comp.hasProvenance);
|
||||
|
||||
// Historical weight: ω_h = 1 / (1 + e^(-kh · (D_i - D_median)))
|
||||
double omegaH = 1.0 / (1 + Math.exp(-0.01 * (comp.daysAgo - 40)));
|
||||
|
||||
double totalWeight = omegaC * omegaT * omegaP * omegaH;
|
||||
|
||||
weightedSum += comp.finalPrice * totalWeight;
|
||||
weightSum += totalWeight;
|
||||
|
||||
// Store for transparency
|
||||
weightedComps.add(new WeightedComparable(comp, totalWeight, omegaC, omegaT, omegaP, omegaH));
|
||||
}
|
||||
|
||||
double baseFMV = weightSum > 0 ? weightedSum / weightSum : (req.estimatedMin + req.estimatedMax) / 2;
|
||||
|
||||
// Apply condition multiplier: M_cond = exp(α_c · √C_target - β_c)
|
||||
double conditionMultiplier = Math.exp(0.15 * Math.sqrt(req.conditionScore) - 0.40);
|
||||
baseFMV *= conditionMultiplier;
|
||||
|
||||
// Apply provenance premium: Δ_prov = V_base · (η_0 + η_1 · ln(1 + N_docs))
|
||||
if (req.provenanceDocs > 0) {
|
||||
double provenancePremium = 0.08 + 0.035 * Math.log(1 + req.provenanceDocs);
|
||||
baseFMV *= (1 + provenancePremium);
|
||||
}
|
||||
|
||||
FairMarketValue fmv = new FairMarketValue();
|
||||
fmv.value = Math.round(baseFMV * 100.0) / 100.0;
|
||||
fmv.conditionMultiplier = Math.round(conditionMultiplier * 1000.0) / 1000.0;
|
||||
fmv.provenancePremium = req.provenanceDocs > 0 ? 0.08 + 0.035 * Math.log(1 + req.provenanceDocs) : 0.0;
|
||||
fmv.comparablesUsed = comparables.size();
|
||||
fmv.confidence = calculateFMVConfidence(comparables.size(), weightSum);
|
||||
fmv.weightedComparables = weightedComps;
|
||||
|
||||
return fmv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates undervaluation score:
|
||||
* U_score = (FMV - P_current)/FMV · σ_market · (1 + B_velocity/10) · ln(1 + W_watch/W_bid)
|
||||
*/
|
||||
private double calculateUndervaluationScore(ValuationRequest req, double fmv) {
|
||||
if (fmv <= 0) return 0.0;
|
||||
|
||||
double priceGap = (fmv - req.currentBid) / fmv;
|
||||
double velocityFactor = 1 + req.bidVelocity / 10.0;
|
||||
double watchRatio = Math.log(1 + req.watchCount / Math.max(req.bidCount, 1));
|
||||
|
||||
double uScore = priceGap * req.marketVolatility * velocityFactor * watchRatio;
|
||||
|
||||
return Math.max(0.0, Math.round(uScore * 1000.0) / 1000.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts final price: P̂_final = FMV · (1 + ε_bid + ε_time + ε_comp)
|
||||
* Where each epsilon represents auction dynamics
|
||||
*/
|
||||
private PricePrediction calculateFinalPrice(ValuationRequest req, double fmv) {
|
||||
// Bid momentum error: ε_bid = tanh(φ_1 · Λ_b - φ_2 · P_current/FMV)
|
||||
double epsilonBid = Math.tanh(0.15 * req.bidVelocity - 0.10 * (req.currentBid / fmv));
|
||||
|
||||
// Time pressure error: ε_time = ψ · exp(-t_close/30)
|
||||
double epsilonTime = 0.20 * Math.exp(-req.minutesUntilClose / 30.0);
|
||||
|
||||
// Competition error: ε_comp = ρ · ln(1 + W_watch/50)
|
||||
double epsilonComp = 0.08 * Math.log(1 + req.watchCount / 50.0);
|
||||
|
||||
double predictedPrice = fmv * (1 + epsilonBid + epsilonTime + epsilonComp);
|
||||
|
||||
// 95% confidence interval: ± 1.96 · σ_residual
|
||||
double residualStdDev = fmv * 0.08; // Mock residual standard deviation
|
||||
double ciLower = predictedPrice - 1.96 * residualStdDev;
|
||||
double ciUpper = predictedPrice + 1.96 * residualStdDev;
|
||||
|
||||
PricePrediction pred = new PricePrediction();
|
||||
pred.predictedPrice = Math.round(predictedPrice * 100.0) / 100.0;
|
||||
pred.confidenceIntervalLower = Math.round(ciLower * 100.0) / 100.0;
|
||||
pred.confidenceIntervalUpper = Math.round(ciUpper * 100.0) / 100.0;
|
||||
pred.components = Map.of(
|
||||
"bidMomentum", Math.round(epsilonBid * 1000.0) / 1000.0,
|
||||
"timePressure", Math.round(epsilonTime * 1000.0) / 1000.0,
|
||||
"competition", Math.round(epsilonComp * 1000.0) / 1000.0
|
||||
);
|
||||
|
||||
return pred;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates optimal bidding strategy based on market conditions
|
||||
*/
|
||||
private BiddingStrategy generateBiddingStrategy(ValuationRequest req, FairMarketValue fmv, PricePrediction pred) {
|
||||
BiddingStrategy strategy = new BiddingStrategy();
|
||||
|
||||
// Determine competition level
|
||||
if (req.bidVelocity > 5.0) {
|
||||
strategy.competitionLevel = "HIGH";
|
||||
strategy.recommendedTiming = "FINAL_30_SECONDS";
|
||||
strategy.maxBid = pred.predictedPrice + 50; // Slight overbid for hot lots
|
||||
strategy.riskFactors = List.of("Bidding war likely", "Sniping detected");
|
||||
} else if (req.minutesUntilClose < 10) {
|
||||
strategy.competitionLevel = "EXTREME";
|
||||
strategy.recommendedTiming = "FINAL_10_SECONDS";
|
||||
strategy.maxBid = pred.predictedPrice * 1.02;
|
||||
strategy.riskFactors = List.of("Last-minute sniping", "Price volatility");
|
||||
} else {
|
||||
strategy.competitionLevel = "MEDIUM";
|
||||
strategy.recommendedTiming = "FINAL_10_MINUTES";
|
||||
|
||||
// Adjust max bid based on undervaluation
|
||||
double undervaluationScore = calculateUndervaluationScore(req, fmv.value);
|
||||
if (undervaluationScore > 0.25) {
|
||||
// Aggressive strategy for undervalued lots
|
||||
strategy.maxBid = fmv.value * (1 + 0.05); // Conservative overbid
|
||||
strategy.analysis = "Significant undervaluation detected. Consider aggressive bidding.";
|
||||
} else {
|
||||
// Standard strategy
|
||||
strategy.maxBid = fmv.value * (1 + 0.03);
|
||||
}
|
||||
strategy.riskFactors = List.of("Standard competition level");
|
||||
}
|
||||
|
||||
// Generate detailed analysis
|
||||
strategy.analysis = String.format(
|
||||
"Bid velocity is %.1f bids/min with %d watchers. %s competition detected. " +
|
||||
"Predicted final: €%.2f (%.0f%% confidence).",
|
||||
req.bidVelocity,
|
||||
req.watchCount,
|
||||
strategy.competitionLevel,
|
||||
pred.predictedPrice,
|
||||
fmv.confidence * 100
|
||||
);
|
||||
|
||||
// Round the max bid
|
||||
strategy.maxBid = Math.round(strategy.maxBid * 100.0) / 100.0;
|
||||
strategy.recommendedTimingText = strategy.recommendedTiming.replace("_", " ");
|
||||
|
||||
return strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates confidence score based on number and quality of comparables
|
||||
*/
|
||||
private double calculateFMVConfidence(int comparableCount, double totalWeight) {
|
||||
double confidence = 0.5; // Base confidence
|
||||
|
||||
// Boost for more comparables
|
||||
confidence += Math.min(comparableCount * 0.05, 0.3);
|
||||
|
||||
// Boost for high total weight (good matches)
|
||||
confidence += Math.min(totalWeight / comparableCount * 0.1, 0.2);
|
||||
|
||||
// Cap at 0.95
|
||||
return Math.min(confidence, 0.95);
|
||||
}
|
||||
|
||||
// ================== DTO Classes ==================
|
||||
|
||||
public static class ValuationRequest {
|
||||
public String lotId;
|
||||
public double currentBid;
|
||||
public double conditionScore; // C_target ∈ [0,10]
|
||||
public int manufacturingYear; // T_target
|
||||
public int watchCount; // W_watch
|
||||
public int bidCount = 1; // W_bid (default 1 to avoid division by zero)
|
||||
public double marketVolatility = 0.15; // σ_market ∈ [0,1]
|
||||
public double bidVelocity; // Λ_b (bids/min)
|
||||
public int minutesUntilClose; // t_close
|
||||
public int provenanceDocs = 0; // N_docs
|
||||
public double estimatedMin;
|
||||
public double estimatedMax;
|
||||
|
||||
// Optional: override parameters for sensitivity analysis
|
||||
public Map<String, Double> sensitivityParams;
|
||||
}
|
||||
|
||||
public static class ValuationResponse {
|
||||
public String lotId;
|
||||
public String timestamp;
|
||||
public FairMarketValue fairMarketValue;
|
||||
public double undervaluationScore;
|
||||
public PricePrediction pricePrediction;
|
||||
public BiddingStrategy biddingStrategy;
|
||||
public ValuationRequest parameters;
|
||||
public long calculationTimeMs;
|
||||
}
|
||||
|
||||
public static class FairMarketValue {
|
||||
public double value;
|
||||
public double conditionMultiplier;
|
||||
public double provenancePremium;
|
||||
public int comparablesUsed;
|
||||
public double confidence; // [0,1]
|
||||
public List<WeightedComparable> weightedComparables;
|
||||
}
|
||||
|
||||
public static class WeightedComparable {
|
||||
public String comparableLotId;
|
||||
public double finalPrice;
|
||||
public double totalWeight;
|
||||
public Map<String, Double> components;
|
||||
|
||||
public WeightedComparable(ComparableLot comp, double totalWeight, double omegaC, double omegaT, double omegaP, double omegaH) {
|
||||
this.comparableLotId = comp.lotId;
|
||||
this.finalPrice = comp.finalPrice;
|
||||
this.totalWeight = Math.round(totalWeight * 1000.0) / 1000.0;
|
||||
this.components = Map.of(
|
||||
"conditionWeight", Math.round(omegaC * 1000.0) / 1000.0,
|
||||
"timeWeight", Math.round(omegaT * 1000.0) / 1000.0,
|
||||
"provenanceWeight", Math.round(omegaP * 1000.0) / 1000.0,
|
||||
"historicalWeight", Math.round(omegaH * 1000.0) / 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PricePrediction {
|
||||
public double predictedPrice;
|
||||
public double confidenceIntervalLower;
|
||||
public double confidenceIntervalUpper;
|
||||
public Map<String, Double> components; // ε_bid, ε_time, ε_comp
|
||||
}
|
||||
|
||||
public static class BiddingStrategy {
|
||||
public String competitionLevel; // LOW, MEDIUM, HIGH, EXTREME
|
||||
public double maxBid;
|
||||
public String recommendedTiming; // FINAL_10_MINUTES, FINAL_30_SECONDS, etc.
|
||||
public String recommendedTimingText;
|
||||
public String analysis;
|
||||
public List<String> riskFactors;
|
||||
}
|
||||
|
||||
// Helper class for internal comparable representation
|
||||
private static class ComparableLot {
|
||||
String lotId;
|
||||
double finalPrice;
|
||||
double conditionScore;
|
||||
int manufacturingYear;
|
||||
int hasProvenance;
|
||||
int daysAgo;
|
||||
|
||||
public ComparableLot(String lotId, double finalPrice, double conditionScore, int manufacturingYear, int hasProvenance, int daysAgo) {
|
||||
this.lotId = lotId;
|
||||
this.finalPrice = finalPrice;
|
||||
this.conditionScore = conditionScore;
|
||||
this.manufacturingYear = manufacturingYear;
|
||||
this.hasProvenance = hasProvenance;
|
||||
this.daysAgo = daysAgo;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ public class WorkflowOrchestrator {
|
||||
notifier.sendNotification(message, "Lot Closing Soon", 1);
|
||||
|
||||
// Mark as notified
|
||||
var updated = new Lot(
|
||||
var updated = Lot.basic(
|
||||
lot.saleId(), lot.lotId(), lot.title(), lot.description(),
|
||||
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
|
||||
lot.currentBid(), lot.currency(), lot.url(),
|
||||
|
||||
7
src/main/resources/META-INF/resources/favicon.svg
Normal file
7
src/main/resources/META-INF/resources/favicon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#2563eb" rx="15"/>
|
||||
<path d="M25 40 L50 20 L75 40 L75 70 L25 70 Z" fill="#ffffff" stroke="#ffffff" stroke-width="2"/>
|
||||
<circle cx="50" cy="45" r="8" fill="#2563eb"/>
|
||||
<rect x="40" y="55" width="20" height="3" fill="#2563eb"/>
|
||||
<text x="50" y="90" font-family="Arial" font-size="12" fill="#ffffff" text-anchor="middle" font-weight="bold">AUCTION</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
File diff suppressed because it is too large
Load Diff
@@ -1,572 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Auctiora - Monitoring Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/plotly.js/3.0.3/plotly.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #60a5fa 100%);
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.status-online {
|
||||
animation: pulse-green 2s infinite;
|
||||
}
|
||||
@keyframes pulse-green {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.workflow-card {
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
.alert-badge {
|
||||
animation: pulse-red 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse-red {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
.metric-card {
|
||||
background: linear-gradient(145deg, #ffffff 0%, #fafbfc 100%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="gradient-bg text-white py-6 shadow-lg">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold mb-2">
|
||||
<i class="fas fa-cube mr-3"></i>
|
||||
Auctiora Monitoring Dashboard
|
||||
</h1>
|
||||
<p class="text-lg opacity-90">Real-time Auction Data Processing & Monitoring</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="status-online inline-block w-3 h-3 bg-green-400 rounded-full"></span>
|
||||
<span class="text-sm font-semibold">System Online</span>
|
||||
</div>
|
||||
<div class="text-xs opacity-75 mt-1" id="last-update">Last updated: --:--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
|
||||
<!-- System Status Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover metric-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-600 text-sm font-semibold uppercase">Auctions</div>
|
||||
<div class="text-3xl font-bold text-blue-600 mt-2" id="total-auctions">--</div>
|
||||
</div>
|
||||
<div class="p-4 rounded-full bg-blue-100">
|
||||
<i class="fas fa-gavel text-2xl text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover metric-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-600 text-sm font-semibold uppercase">Total Lots</div>
|
||||
<div class="text-3xl font-bold text-green-600 mt-2" id="total-lots">--</div>
|
||||
</div>
|
||||
<div class="p-4 rounded-full bg-green-100">
|
||||
<i class="fas fa-boxes text-2xl text-green-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover metric-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-600 text-sm font-semibold uppercase">Images</div>
|
||||
<div class="text-3xl font-bold text-purple-600 mt-2" id="total-images">--</div>
|
||||
</div>
|
||||
<div class="p-4 rounded-full bg-purple-100">
|
||||
<i class="fas fa-images text-2xl text-purple-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover metric-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-600 text-sm font-semibold uppercase">Active Lots</div>
|
||||
<div class="text-3xl font-bold text-yellow-600 mt-2" id="active-lots">--</div>
|
||||
</div>
|
||||
<div class="p-4 rounded-full bg-yellow-100">
|
||||
<i class="fas fa-clock text-2xl text-yellow-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover metric-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-600 text-sm font-semibold uppercase">Closing Soon</div>
|
||||
<div class="text-3xl font-bold text-red-600 mt-2" id="closing-soon">--</div>
|
||||
</div>
|
||||
<div class="p-4 rounded-full bg-red-100">
|
||||
<i class="fas fa-bell text-2xl text-red-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Overview -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 col-span-2">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">
|
||||
<i class="fas fa-chart-line mr-2 text-blue-600"></i>
|
||||
Bidding Statistics
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<div class="text-gray-600 text-sm font-semibold">Lots with Bids</div>
|
||||
<div class="text-2xl font-bold text-blue-600 mt-2" id="lots-with-bids">--</div>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-green-50 rounded-lg">
|
||||
<div class="text-gray-600 text-sm font-semibold">Total Bid Value</div>
|
||||
<div class="text-2xl font-bold text-green-600 mt-2" id="total-bid-value">--</div>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<div class="text-gray-600 text-sm font-semibold">Average Bid</div>
|
||||
<div class="text-2xl font-bold text-purple-600 mt-2" id="average-bid">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">
|
||||
<i class="fas fa-tachometer-alt mr-2 text-green-600"></i>
|
||||
Rate Limiting
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm">Total Requests</span>
|
||||
<span class="font-bold text-gray-800" id="rate-total-requests">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm">Successful</span>
|
||||
<span class="font-bold text-green-600" id="rate-successful">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm">Failed</span>
|
||||
<span class="font-bold text-red-600" id="rate-failed">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm">Avg Duration</span>
|
||||
<span class="font-bold text-blue-600" id="rate-avg-duration">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workflow Controls -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">
|
||||
<i class="fas fa-play-circle mr-2 text-blue-600"></i>
|
||||
Workflow Controls
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button onclick="triggerWorkflow('scraper-import')"
|
||||
class="workflow-card p-4 rounded-lg hover:shadow-lg transition-all">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<i class="fas fa-download text-blue-600 text-xl"></i>
|
||||
<span class="text-xs text-gray-500" id="status-scraper">Ready</span>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-800">Import Scraper Data</div>
|
||||
<div class="text-xs text-gray-600 mt-1">Load auction/lot data from external scraper</div>
|
||||
</button>
|
||||
|
||||
<button onclick="triggerWorkflow('image-processing')"
|
||||
class="workflow-card p-4 rounded-lg hover:shadow-lg transition-all">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<i class="fas fa-image text-purple-600 text-xl"></i>
|
||||
<span class="text-xs text-gray-500" id="status-images">Ready</span>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-800">Process Images</div>
|
||||
<div class="text-xs text-gray-600 mt-1">Download & analyze images with object detection</div>
|
||||
</button>
|
||||
|
||||
<button onclick="triggerWorkflow('bid-monitoring')"
|
||||
class="workflow-card p-4 rounded-lg hover:shadow-lg transition-all">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<i class="fas fa-chart-line text-green-600 text-xl"></i>
|
||||
<span class="text-xs text-gray-500" id="status-bids">Ready</span>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-800">Monitor Bids</div>
|
||||
<div class="text-xs text-gray-600 mt-1">Track bid changes and send notifications</div>
|
||||
</button>
|
||||
|
||||
<button onclick="triggerWorkflow('closing-alerts')"
|
||||
class="workflow-card p-4 rounded-lg hover:shadow-lg transition-all">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<i class="fas fa-bell text-red-600 text-xl"></i>
|
||||
<span class="text-xs text-gray-500" id="status-alerts">Ready</span>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-800">Closing Alerts</div>
|
||||
<div class="text-xs text-gray-600 mt-1">Alert for lots closing within 30 minutes</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Country Distribution -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">
|
||||
<i class="fas fa-globe mr-2 text-blue-600"></i>
|
||||
Auctions by Country
|
||||
</h3>
|
||||
<div id="country-chart" style="height: 300px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Category Distribution -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">
|
||||
<i class="fas fa-tags mr-2 text-green-600"></i>
|
||||
Lots by Category
|
||||
</h3>
|
||||
<div id="category-chart" style="height: 300px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Closing Soon Table -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-xl font-semibold text-gray-800">
|
||||
<i class="fas fa-exclamation-triangle mr-2 text-red-600"></i>
|
||||
Lots Closing Soon (< 30 min)
|
||||
</h3>
|
||||
<button onclick="fetchClosingSoon()" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
|
||||
<i class="fas fa-sync mr-2"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Lot ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Current Bid</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Closing Time</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Minutes Left</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="closing-soon-table" class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-4 text-center text-gray-500">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity Log -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">
|
||||
<i class="fas fa-history mr-2 text-purple-600"></i>
|
||||
Activity Log
|
||||
</h3>
|
||||
<div id="activity-log" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div class="text-sm text-gray-500">Monitoring system started...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-800 text-white py-6 mt-12">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<p class="text-gray-300">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
Auctiora - Auction Data Processing & Monitoring System
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 mt-2">
|
||||
Architecture: Quarkus + SQLite + REST API | Auto-refresh: 15 seconds
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Dashboard state
|
||||
let dashboardData = {
|
||||
status: {},
|
||||
statistics: {},
|
||||
rateLimitStats: {},
|
||||
closingSoon: []
|
||||
};
|
||||
|
||||
// Initialize dashboard
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
addLog('Dashboard initialized');
|
||||
fetchAllData();
|
||||
|
||||
// Auto-refresh every 15 seconds
|
||||
setInterval(fetchAllData, 15000);
|
||||
});
|
||||
|
||||
// Fetch all dashboard data
|
||||
async function fetchAllData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchStatus(),
|
||||
fetchStatistics(),
|
||||
fetchRateLimitStats(),
|
||||
fetchClosingSoon()
|
||||
]);
|
||||
updateLastUpdate();
|
||||
addLog('Dashboard data refreshed');
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
addLog('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch system status
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/monitor/status');
|
||||
if (!response.ok) throw new Error('Failed to fetch status');
|
||||
|
||||
dashboardData.status = await response.json();
|
||||
|
||||
document.getElementById('total-auctions').textContent = dashboardData.status.auctions || 0;
|
||||
document.getElementById('total-lots').textContent = dashboardData.status.lots || 0;
|
||||
document.getElementById('total-images').textContent = dashboardData.status.images || 0;
|
||||
document.getElementById('closing-soon').textContent = dashboardData.status.closingSoon || 0;
|
||||
} catch (error) {
|
||||
console.error('Error fetching status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch statistics
|
||||
async function fetchStatistics() {
|
||||
try {
|
||||
const response = await fetch('/api/monitor/statistics');
|
||||
if (!response.ok) throw new Error('Failed to fetch statistics');
|
||||
|
||||
dashboardData.statistics = await response.json();
|
||||
|
||||
document.getElementById('active-lots').textContent = dashboardData.statistics.activeLots || 0;
|
||||
document.getElementById('lots-with-bids').textContent = dashboardData.statistics.lotsWithBids || 0;
|
||||
document.getElementById('total-bid-value').textContent = dashboardData.statistics.totalBidValue || '€0.00';
|
||||
document.getElementById('average-bid').textContent = dashboardData.statistics.averageBid || '€0.00';
|
||||
} catch (error) {
|
||||
console.error('Error fetching statistics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch rate limit stats
|
||||
async function fetchRateLimitStats() {
|
||||
try {
|
||||
const response = await fetch('/api/monitor/rate-limit/stats');
|
||||
if (!response.ok) throw new Error('Failed to fetch rate limit stats');
|
||||
|
||||
dashboardData.rateLimitStats = await response.json();
|
||||
|
||||
// Aggregate stats from all hosts
|
||||
let totalRequests = 0, successfulRequests = 0, failedRequests = 0, avgDuration = 0;
|
||||
const stats = dashboardData.rateLimitStats.statistics || {};
|
||||
const hostCount = Object.keys(stats).length;
|
||||
|
||||
for (const hostStats of Object.values(stats)) {
|
||||
totalRequests += hostStats.totalRequests || 0;
|
||||
successfulRequests += hostStats.successfulRequests || 0;
|
||||
failedRequests += hostStats.failedRequests || 0;
|
||||
avgDuration += hostStats.averageDurationMs || 0;
|
||||
}
|
||||
|
||||
document.getElementById('rate-total-requests').textContent = totalRequests;
|
||||
document.getElementById('rate-successful').textContent = successfulRequests;
|
||||
document.getElementById('rate-failed').textContent = failedRequests;
|
||||
document.getElementById('rate-avg-duration').textContent =
|
||||
hostCount > 0 ? (avgDuration / hostCount).toFixed(0) + ' ms' : '--';
|
||||
} catch (error) {
|
||||
console.error('Error fetching rate limit stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch closing soon lots
|
||||
async function fetchClosingSoon() {
|
||||
try {
|
||||
const response = await fetch('/api/monitor/lots/closing-soon?minutes=30');
|
||||
if (!response.ok) throw new Error('Failed to fetch closing soon lots');
|
||||
|
||||
dashboardData.closingSoon = await response.json();
|
||||
updateClosingSoonTable();
|
||||
|
||||
// Update charts (placeholder for now)
|
||||
updateCharts();
|
||||
} catch (error) {
|
||||
console.error('Error fetching closing soon:', error);
|
||||
document.getElementById('closing-soon-table').innerHTML =
|
||||
'<tr><td colspan="6" class="px-6 py-4 text-center text-red-600">Error loading data</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Update closing soon table
|
||||
function updateClosingSoonTable() {
|
||||
const tableBody = document.getElementById('closing-soon-table');
|
||||
|
||||
if (dashboardData.closingSoon.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="6" class="px-6 py-4 text-center text-gray-500">No lots closing soon</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
dashboardData.closingSoon.forEach(lot => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-50';
|
||||
|
||||
const minutesLeft = lot.minutesUntilClose || 0;
|
||||
const badgeColor = minutesLeft < 10 ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${lot.lotId || '--'}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">${lot.title || 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-green-600">${lot.currency || ''}${lot.currentBid ? lot.currentBid.toFixed(2) : '0.00'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${lot.closingTime || 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${badgeColor}">
|
||||
${minutesLeft} min
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a href="${lot.url || '#'}" target="_blank" class="text-blue-600 hover:text-blue-900">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Update charts (placeholder)
|
||||
function updateCharts() {
|
||||
// Country distribution pie chart
|
||||
const countryData = {
|
||||
values: [5, 3, 2],
|
||||
labels: ['NL', 'BE', 'DE'],
|
||||
type: 'pie',
|
||||
marker: { colors: ['#3b82f6', '#10b981', '#f59e0b'] }
|
||||
};
|
||||
|
||||
Plotly.newPlot('country-chart', [countryData], {
|
||||
showlegend: true,
|
||||
margin: { t: 20, b: 20, l: 20, r: 20 }
|
||||
});
|
||||
|
||||
// Category distribution
|
||||
const categoryData = {
|
||||
values: [4, 3, 2, 1],
|
||||
labels: ['Machinery', 'Material Handling', 'Power Generation', 'Furniture'],
|
||||
type: 'pie',
|
||||
marker: { colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'] }
|
||||
};
|
||||
|
||||
Plotly.newPlot('category-chart', [categoryData], {
|
||||
showlegend: true,
|
||||
margin: { t: 20, b: 20, l: 20, r: 20 }
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger workflow
|
||||
async function triggerWorkflow(workflow) {
|
||||
const statusId = 'status-' + workflow.split('-')[0];
|
||||
const statusEl = document.getElementById(statusId);
|
||||
|
||||
statusEl.textContent = 'Running...';
|
||||
statusEl.className = 'text-xs text-blue-600 font-semibold';
|
||||
|
||||
addLog(`Triggering workflow: ${workflow}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/monitor/trigger/${workflow}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Workflow trigger failed');
|
||||
|
||||
const result = await response.json();
|
||||
addLog(`✓ ${result.message || 'Workflow completed'}`, 'success');
|
||||
statusEl.textContent = 'Complete';
|
||||
statusEl.className = 'text-xs text-green-600 font-semibold';
|
||||
|
||||
// Refresh data after workflow
|
||||
setTimeout(fetchAllData, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error triggering workflow:', error);
|
||||
addLog(`✗ Workflow failed: ${error.message}`, 'error');
|
||||
statusEl.textContent = 'Failed';
|
||||
statusEl.className = 'text-xs text-red-600 font-semibold';
|
||||
}
|
||||
|
||||
// Reset status after 5 seconds
|
||||
setTimeout(() => {
|
||||
statusEl.textContent = 'Ready';
|
||||
statusEl.className = 'text-xs text-gray-500';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Add log entry
|
||||
function addLog(message, type = 'info') {
|
||||
const logContainer = document.getElementById('activity-log');
|
||||
const logEntry = document.createElement('div');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
|
||||
let iconClass = 'fas fa-info-circle text-blue-600';
|
||||
let textClass = 'text-gray-700';
|
||||
|
||||
if (type === 'success') {
|
||||
iconClass = 'fas fa-check-circle text-green-600';
|
||||
textClass = 'text-green-700';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'fas fa-exclamation-circle text-red-600';
|
||||
textClass = 'text-red-700';
|
||||
}
|
||||
|
||||
logEntry.className = `text-sm flex items-start space-x-2 ${textClass}`;
|
||||
logEntry.innerHTML = `
|
||||
<i class="${iconClass} mt-1"></i>
|
||||
<span class="text-gray-500">${timestamp}</span>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
|
||||
logContainer.insertBefore(logEntry, logContainer.firstChild);
|
||||
|
||||
// Keep only last 20 entries
|
||||
while (logContainer.children.length > 20) {
|
||||
logContainer.removeChild(logContainer.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last update timestamp
|
||||
function updateLastUpdate() {
|
||||
const now = new Date();
|
||||
document.getElementById('last-update').textContent =
|
||||
`Last updated: ${now.toLocaleTimeString()}`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
0
src/main/resources/META-INF/resources/script.js
Normal file
0
src/main/resources/META-INF/resources/script.js
Normal file
224
src/main/resources/META-INF/resources/status.html
Normal file
224
src/main/resources/META-INF/resources/status.html
Normal file
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Scrape-UI 1 - Enterprise</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="gradient-bg text-white py-8">
|
||||
<div class="container mx-auto px-4">
|
||||
<h1 class="text-4xl font-bold mb-2">Scrape-UI Enterprise</h1>
|
||||
<p class="text-xl opacity-90">Powered by Quarkus + Modern Frontend</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- API Status Card -->
|
||||
<!-- API & Build Status Card -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8 card-hover">
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">Build & Runtime Status</h2>
|
||||
<div id="api-status" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Build Information -->
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">📦 Maven Build</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Group:</span>
|
||||
<span class="font-mono font-medium" id="build-group">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Artifact:</span>
|
||||
<span class="font-mono font-medium" id="build-artifact">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Version:</span>
|
||||
<span class="font-mono font-medium px-2 py-1 bg-blue-100 rounded" id="build-version">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Runtime Information -->
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<h3 class="font-semibold text-green-800 mb-2">🚀 Runtime</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Status:</span>
|
||||
<span class="px-2 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800" id="runtime-status">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Java:</span>
|
||||
<span class="font-mono" id="java-version">-</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Platform:</span>
|
||||
<span class="font-mono" id="runtime-os">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp & Additional Info -->
|
||||
<div class="pt-4 border-t">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Last Updated</p>
|
||||
<p class="font-medium" id="last-updated">-</p>
|
||||
</div>
|
||||
<button onclick="fetchStatus()" class="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm transition-colors">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Response Card -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover">
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">API Test</h2>
|
||||
<button id="test-api" class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors mb-4">
|
||||
Test Greeting API
|
||||
</button>
|
||||
<div id="api-response" class="bg-gray-100 p-4 rounded-lg">
|
||||
<pre class="text-sm text-gray-700">Click the button to test the API</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover">
|
||||
<h3 class="text-xl font-semibold mb-2 text-gray-800">⚡ Quarkus Backend</h3>
|
||||
<p class="text-gray-600">Fast startup, low memory footprint, optimized for containers</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover">
|
||||
<h3 class="text-xl font-semibold mb-2 text-gray-800">🚀 REST API</h3>
|
||||
<p class="text-gray-600">RESTful endpoints with JSON responses</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 card-hover">
|
||||
<h3 class="text-xl font-semibold mb-2 text-gray-800">🎨 Modern UI</h3>
|
||||
<p class="text-gray-600">Responsive design with Tailwind CSS</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Fetch API status on load
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/status')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${ response.status }: ${ response.statusText }`)
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
// Update Build Information
|
||||
document.getElementById('build-group').textContent = data.groupId || 'N/A'
|
||||
document.getElementById('build-artifact').textContent = data.artifactId || data.name || 'N/A'
|
||||
document.getElementById('build-version').textContent = data.version || 'N/A'
|
||||
|
||||
// Update Runtime Information
|
||||
document.getElementById('runtime-status').textContent = data.status || 'unknown'
|
||||
document.getElementById('java-version').textContent = data.javaVersion || System.getProperty?.('java.version') || 'N/A'
|
||||
document.getElementById('runtime-os').textContent = data.os || 'N/A'
|
||||
|
||||
// Update Timestamp
|
||||
const timestamp = data.timestamp ? new Date(data.timestamp).toLocaleString() : 'N/A'
|
||||
document.getElementById('last-updated').textContent = timestamp
|
||||
|
||||
// Update status badge color based on status
|
||||
const statusBadge = document.getElementById('runtime-status')
|
||||
if (data.status?.toLowerCase() === 'running') {
|
||||
statusBadge.className = 'px-2 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800'
|
||||
} else {
|
||||
statusBadge.className = 'px-2 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800'
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching status:', error)
|
||||
document.getElementById('api-status').innerHTML = `
|
||||
<div class="bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-700">Failed to load status: ${ error.message }</p>
|
||||
<button onclick="fetchStatus()" class="mt-2 text-sm text-red-700 hover:text-red-600 font-medium">
|
||||
Retry →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch API status on load
|
||||
async function fetchStatus3() {
|
||||
try {
|
||||
const response = await fetch('/api/status')
|
||||
const data = await response.json()
|
||||
document.getElementById('api-status').innerHTML = `
|
||||
<p><strong>Application:</strong> ${ data.application }</p>
|
||||
<p><strong>Status:</strong> <span class="text-green-600 font-semibold">${ data.status }</span></p>
|
||||
<p><strong>Version:</strong> ${ data.version }</p>
|
||||
<p><strong>Timestamp:</strong> ${ data.timestamp }</p>
|
||||
`
|
||||
} catch (error) {
|
||||
document.getElementById('api-status').innerHTML = `
|
||||
<p class="text-red-600">Error loading status: ${ error.message }</p>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// Test greeting API
|
||||
document.getElementById('test-api').addEventListener('click', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/hello')
|
||||
const data = await response.json()
|
||||
document.getElementById('api-response').innerHTML = `
|
||||
<pre class="text-sm text-gray-700">${ JSON.stringify(data, null, 2) }</pre>
|
||||
`
|
||||
} catch (error) {
|
||||
document.getElementById('api-response').innerHTML = `
|
||||
<pre class="text-sm text-red-600">Error: ${ error.message }</pre>
|
||||
`
|
||||
}
|
||||
})
|
||||
// Auto-refresh every 30 seconds
|
||||
let refreshInterval = setInterval(fetchStatus, 30000);
|
||||
|
||||
// Stop auto-refresh when page loses focus (optional)
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) {
|
||||
clearInterval(refreshInterval);
|
||||
} else {
|
||||
refreshInterval = setInterval(fetchStatus, 30000);
|
||||
fetchStatus(); // Refresh immediately when returning to tab
|
||||
}
|
||||
});
|
||||
// Load status on page load
|
||||
fetchStatus()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
0
src/main/resources/META-INF/resources/style.css
Normal file
0
src/main/resources/META-INF/resources/style.css
Normal file
1086
src/main/resources/META-INF/resources/valuation-analytics.html
Normal file
1086
src/main/resources/META-INF/resources/valuation-analytics.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -143,7 +143,7 @@ class DatabaseServiceTest {
|
||||
@Test
|
||||
@DisplayName("Should insert and retrieve lot")
|
||||
void testUpsertAndGetLot() throws SQLException {
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
12345, // saleId
|
||||
67890, // lotId
|
||||
"Forklift",
|
||||
@@ -180,7 +180,7 @@ class DatabaseServiceTest {
|
||||
@Test
|
||||
@DisplayName("Should update lot current bid")
|
||||
void testUpdateLotCurrentBid() throws SQLException {
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
11111, 22222, "Test Item", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/22222", null, false
|
||||
);
|
||||
@@ -188,7 +188,7 @@ class DatabaseServiceTest {
|
||||
db.upsertLot(lot);
|
||||
|
||||
// Update bid
|
||||
var updatedLot = new Lot(
|
||||
var updatedLot = Lot.basic(
|
||||
11111, 22222, "Test Item", "Description", "", "", 0, "Category",
|
||||
250.00, "EUR", "https://example.com/lot/22222", null, false
|
||||
);
|
||||
@@ -208,7 +208,7 @@ class DatabaseServiceTest {
|
||||
@Test
|
||||
@DisplayName("Should update lot notification flags")
|
||||
void testUpdateLotNotificationFlags() throws SQLException {
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
33333, 44444, "Test Item", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/44444", null, false
|
||||
);
|
||||
@@ -216,7 +216,7 @@ class DatabaseServiceTest {
|
||||
db.upsertLot(lot);
|
||||
|
||||
// Update notification flag
|
||||
var updatedLot = new Lot(
|
||||
var updatedLot = Lot.basic(
|
||||
33333, 44444, "Test Item", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/44444", null, true
|
||||
);
|
||||
@@ -237,7 +237,7 @@ class DatabaseServiceTest {
|
||||
@DisplayName("Should insert and retrieve image records")
|
||||
void testInsertAndGetImages() throws SQLException {
|
||||
// First create a lot
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
55555, 66666, "Test Lot", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/66666", null, false
|
||||
);
|
||||
@@ -268,7 +268,7 @@ class DatabaseServiceTest {
|
||||
int initialCount = db.getImageCount();
|
||||
|
||||
// Add a lot and image
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
77777, 88888, "Test Lot", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/88888", null, false
|
||||
);
|
||||
@@ -304,7 +304,7 @@ class DatabaseServiceTest {
|
||||
@Test
|
||||
@DisplayName("Should handle lots with null closing time")
|
||||
void testLotWithNullClosingTime() throws SQLException {
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
98765, 12340, "Test Item", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/12340", null, false
|
||||
);
|
||||
@@ -323,7 +323,7 @@ class DatabaseServiceTest {
|
||||
@Test
|
||||
@DisplayName("Should retrieve active lots only")
|
||||
void testGetActiveLots() throws SQLException {
|
||||
var activeLot = new Lot(
|
||||
var activeLot = Lot.basic(
|
||||
11111, 55551, "Active Lot", "Description", "", "", 0, "Category",
|
||||
100.00, "EUR", "https://example.com/lot/55551",
|
||||
LocalDateTime.now().plusDays(1), false
|
||||
@@ -345,7 +345,7 @@ class DatabaseServiceTest {
|
||||
Thread t1 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
db.upsertLot(new Lot(
|
||||
db.upsertLot(Lot.basic(
|
||||
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
@@ -358,7 +358,7 @@ class DatabaseServiceTest {
|
||||
Thread t2 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 10; i < 20; i++) {
|
||||
db.upsertLot(new Lot(
|
||||
db.upsertLot(Lot.basic(
|
||||
99000 + i, 99100 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
200.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
|
||||
@@ -84,7 +84,7 @@ class IntegrationTest {
|
||||
db.upsertAuction(auction);
|
||||
|
||||
// Step 2: Import lots for this auction
|
||||
var lot1 = new Lot(
|
||||
var lot1 = Lot.basic(
|
||||
12345, 10001,
|
||||
"Toyota Forklift 2.5T",
|
||||
"Electric forklift in excellent condition",
|
||||
@@ -99,7 +99,7 @@ class IntegrationTest {
|
||||
false
|
||||
);
|
||||
|
||||
var lot2 = new Lot(
|
||||
var lot2 = Lot.basic(
|
||||
12345, 10002,
|
||||
"Office Furniture Set",
|
||||
"Desks, chairs, and cabinets",
|
||||
@@ -159,7 +159,7 @@ class IntegrationTest {
|
||||
.orElseThrow();
|
||||
|
||||
// Update bid
|
||||
var updatedLot = new Lot(
|
||||
var updatedLot = Lot.basic(
|
||||
lot.saleId(), lot.lotId(), lot.title(), lot.description(),
|
||||
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
|
||||
2000.00, // Increased from 1500.00
|
||||
@@ -190,7 +190,7 @@ class IntegrationTest {
|
||||
@DisplayName("Integration: Closing alert workflow")
|
||||
void testClosingAlertWorkflow() throws SQLException {
|
||||
// Create lot closing soon
|
||||
var closingSoon = new Lot(
|
||||
var closingSoon = Lot.basic(
|
||||
12345, 20001,
|
||||
"Closing Soon Item",
|
||||
"Description",
|
||||
@@ -217,7 +217,7 @@ class IntegrationTest {
|
||||
);
|
||||
|
||||
// Mark as notified
|
||||
var notified = new Lot(
|
||||
var notified = Lot.basic(
|
||||
closingSoon.saleId(), closingSoon.lotId(), closingSoon.title(),
|
||||
closingSoon.description(), closingSoon.manufacturer(), closingSoon.type(),
|
||||
closingSoon.year(), closingSoon.category(), closingSoon.currentBid(),
|
||||
@@ -310,7 +310,7 @@ class IntegrationTest {
|
||||
@DisplayName("Integration: Object detection value estimation workflow")
|
||||
void testValueEstimationWorkflow() throws SQLException {
|
||||
// Create lot with detected objects
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
40000, 50000,
|
||||
"Construction Equipment",
|
||||
"Heavy machinery for construction",
|
||||
@@ -378,7 +378,7 @@ class IntegrationTest {
|
||||
var lotThread = new Thread(() -> {
|
||||
try {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
db.upsertLot(new Lot(
|
||||
db.upsertLot(Lot.basic(
|
||||
60000 + i, 70000 + i, "Concurrent Lot " + i, "Desc", "", "", 0, "Cat",
|
||||
100.0 * i, "EUR", "https://example.com/70" + i, null, false
|
||||
));
|
||||
|
||||
@@ -73,7 +73,7 @@ class TroostwijkMonitorTest {
|
||||
@DisplayName("Should track lots in database")
|
||||
void testLotTracking() throws SQLException {
|
||||
// Insert test lot
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
11111, 22222,
|
||||
"Test Forklift",
|
||||
"Electric forklift in good condition",
|
||||
@@ -98,7 +98,7 @@ class TroostwijkMonitorTest {
|
||||
@DisplayName("Should monitor lots closing soon")
|
||||
void testClosingSoonMonitoring() throws SQLException {
|
||||
// Insert lot closing in 4 minutes
|
||||
var closingSoon = new Lot(
|
||||
var closingSoon = Lot.basic(
|
||||
33333, 44444,
|
||||
"Closing Soon Item",
|
||||
"Description",
|
||||
@@ -128,7 +128,7 @@ class TroostwijkMonitorTest {
|
||||
@Test
|
||||
@DisplayName("Should identify lots with time remaining")
|
||||
void testTimeRemainingCalculation() throws SQLException {
|
||||
var futureLot = new Lot(
|
||||
var futureLot = Lot.basic(
|
||||
55555, 66666,
|
||||
"Future Lot",
|
||||
"Description",
|
||||
@@ -158,7 +158,7 @@ class TroostwijkMonitorTest {
|
||||
@Test
|
||||
@DisplayName("Should handle lots without closing time")
|
||||
void testLotsWithoutClosingTime() throws SQLException {
|
||||
var noClosing = new Lot(
|
||||
var noClosing = Lot.basic(
|
||||
77777, 88888,
|
||||
"No Closing Time",
|
||||
"Description",
|
||||
@@ -188,7 +188,7 @@ class TroostwijkMonitorTest {
|
||||
@Test
|
||||
@DisplayName("Should track notification status")
|
||||
void testNotificationStatusTracking() throws SQLException {
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
99999, 11110,
|
||||
"Test Notification",
|
||||
"Description",
|
||||
@@ -206,7 +206,7 @@ class TroostwijkMonitorTest {
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
// Update notification flag
|
||||
var notified = new Lot(
|
||||
var notified = Lot.basic(
|
||||
99999, 11110,
|
||||
"Test Notification",
|
||||
"Description",
|
||||
@@ -236,7 +236,7 @@ class TroostwijkMonitorTest {
|
||||
@Test
|
||||
@DisplayName("Should update bid amounts")
|
||||
void testBidAmountUpdates() throws SQLException {
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
12121, 13131,
|
||||
"Bid Update Test",
|
||||
"Description",
|
||||
@@ -254,7 +254,7 @@ class TroostwijkMonitorTest {
|
||||
monitor.getDb().upsertLot(lot);
|
||||
|
||||
// Simulate bid increase
|
||||
var higherBid = new Lot(
|
||||
var higherBid = Lot.basic(
|
||||
12121, 13131,
|
||||
"Bid Update Test",
|
||||
"Description",
|
||||
@@ -287,7 +287,7 @@ class TroostwijkMonitorTest {
|
||||
Thread t1 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
monitor.getDb().upsertLot(new Lot(
|
||||
monitor.getDb().upsertLot(Lot.basic(
|
||||
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
100.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
@@ -300,7 +300,7 @@ class TroostwijkMonitorTest {
|
||||
Thread t2 = new Thread(() -> {
|
||||
try {
|
||||
for (int i = 5; i < 10; i++) {
|
||||
monitor.getDb().upsertLot(new Lot(
|
||||
monitor.getDb().upsertLot(Lot.basic(
|
||||
20000 + i, 30000 + i, "Concurrent " + i, "Desc", "", "", 0, "Cat",
|
||||
200.0, "EUR", "https://example.com/" + i, null, false
|
||||
));
|
||||
@@ -354,7 +354,7 @@ class TroostwijkMonitorTest {
|
||||
monitor.getDb().upsertAuction(auction);
|
||||
|
||||
// Insert related lot
|
||||
var lot = new Lot(
|
||||
var lot = Lot.basic(
|
||||
40000, 50000,
|
||||
"Test Lot",
|
||||
"Description",
|
||||
|
||||
Reference in New Issue
Block a user