Compare commits

...

8 Commits

Author SHA1 Message Date
Tour
ca19649b6a Features 2025-12-07 11:41:22 +01:00
Tour
65bb5cd80a Features 2025-12-07 11:31:55 +01:00
Tour
43b5fc03fd Fix mock tests 2025-12-07 11:08:59 +01:00
Tour
11a76e0292 Fix mock tests 2025-12-07 09:59:08 +01:00
Tour
a649b629e4 Fix mock tests 2025-12-07 06:51:18 +01:00
Tour
3efa83bc44 Fix mock tests 2025-12-07 06:32:03 +01:00
Tour
ef804b3896 Fix mock tests 2025-12-07 06:28:37 +01:00
Tour
f561a73b01 Fix mock tests 2025-12-07 02:36:00 +01:00
57 changed files with 7370 additions and 1167 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View File

@@ -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

View 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
View 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
View 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)
);

View 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
View 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.

View 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

View 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

View File

@@ -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
```

View File

@@ -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
View 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} &gt; 0.25$ (25% undervaluation) with confidence &gt; 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 &gt; 5$ = **Hot lot** (bidding war likely)
- $\Lambda_b &lt; 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} &gt; 0.20 \\
FMV \cdot (1 + \theta_{cons}) & \text{if } \Lambda_b &gt; 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 &lt; 1 \\
30 \text{ sec} & \text{if } \Lambda_b &gt; 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&lt;Comparable&gt; 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 &gt; 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 &gt; 50 && bidHistory.size() &lt; 5;
boolean hasReserve = lot.getReservePrice() &gt; 0;
double bidVelocity = calculateBidVelocity(bidHistory);
// Strategy recommendation
String strategy = isSnipeTarget ? "SNIPING_DETECTED" :
(hasReserve && lot.getCurrentBid() &lt; lot.getReservePrice() * 0.9) ? "RESERVE_AVOID" :
bidVelocity &gt; 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
);
```

View File

@@ -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

View File

@@ -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!

View File

@@ -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%

View File

@@ -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

View File

@@ -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
View 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
View 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 ""

View 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 ""

View File

@@ -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;
}
}
}

View 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
) {}

View File

@@ -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.

View File

@@ -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);

View File

@@ -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
);
}
}

View 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);
}
}
}

View 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)
);
}
}

View 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
) {}

View File

@@ -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 {
// Postprocess: 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;
}
/**

View File

@@ -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(),

View File

@@ -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
);
}

View 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;
}
}

View 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;
}
}
}

View File

@@ -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(),

View 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

View File

@@ -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>

View 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>

File diff suppressed because it is too large Load Diff

View File

@@ -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
));

View File

@@ -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
));

View File

@@ -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",