@@ -1,113 +0,0 @@
|
|||||||
# Database Schema Fix Instructions
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
The server database was created with `BIGINT` primary keys for `auction_id` and `lot_id`, but the scraper uses TEXT IDs like "A7-40063-2". This causes PRIMARY KEY constraint failures.
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
- Local DB: `auction_id TEXT PRIMARY KEY`, `lot_id TEXT PRIMARY KEY`
|
|
||||||
- Server DB (old): `auction_id BIGINT PRIMARY KEY`, `lot_id BIGINT PRIMARY KEY`
|
|
||||||
- Scraper data: Uses TEXT IDs like "A7-40063-2", "A1-34732-49"
|
|
||||||
|
|
||||||
This mismatch prevents the scraper from inserting data, resulting in zero bids showing in the UI.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
### Step 1: Backup Server Database
|
|
||||||
```bash
|
|
||||||
ssh tour@192.168.1.149
|
|
||||||
cd /mnt/okcomputer/output
|
|
||||||
cp cache.db cache.db.backup.$(date +%Y%m%d_%H%M%S)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Upload Fix Script
|
|
||||||
From your local machine:
|
|
||||||
```bash
|
|
||||||
scp C:\vibe\auctiora\fix-schema.sql tour@192.168.1.149:/tmp/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Stop the Application
|
|
||||||
```bash
|
|
||||||
ssh tour@192.168.1.149
|
|
||||||
cd /path/to/docker/compose # wherever docker-compose.yml is located
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Apply Schema Fix
|
|
||||||
```bash
|
|
||||||
ssh tour@192.168.1.149
|
|
||||||
cd /mnt/okcomputer/output
|
|
||||||
sqlite3 cache.db < /tmp/fix-schema.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Verify Schema
|
|
||||||
```bash
|
|
||||||
sqlite3 cache.db "PRAGMA table_info(auctions);"
|
|
||||||
# Should show: auction_id TEXT PRIMARY KEY
|
|
||||||
|
|
||||||
sqlite3 cache.db "PRAGMA table_info(lots);"
|
|
||||||
# Should show: lot_id TEXT PRIMARY KEY, sale_id TEXT, auction_id TEXT
|
|
||||||
|
|
||||||
# Check data integrity
|
|
||||||
sqlite3 cache.db "SELECT COUNT(*) FROM auctions;"
|
|
||||||
sqlite3 cache.db "SELECT COUNT(*) FROM lots;"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Rebuild Application with Fixed Schema
|
|
||||||
```bash
|
|
||||||
# Build new image with fixed DatabaseService.java
|
|
||||||
cd C:\vibe\auctiora
|
|
||||||
./mvnw clean package -DskipTests
|
|
||||||
|
|
||||||
# Copy new JAR to server
|
|
||||||
scp target/quarkus-app/quarkus-run.jar tour@192.168.1.149:/path/to/app/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7: Restart Application
|
|
||||||
```bash
|
|
||||||
ssh tour@192.168.1.149
|
|
||||||
cd /path/to/docker/compose
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 8: Verify Fix
|
|
||||||
```bash
|
|
||||||
# Check logs for successful imports
|
|
||||||
docker-compose logs -f --tail=100
|
|
||||||
|
|
||||||
# Should see:
|
|
||||||
# ✓ Imported XXX auctions
|
|
||||||
# ✓ Imported XXXXX lots
|
|
||||||
# No more "PRIMARY KEY constraint failed" errors
|
|
||||||
|
|
||||||
# Check UI at http://192.168.1.149:8081/
|
|
||||||
# Should now show:
|
|
||||||
# - Lots with Bids: > 0
|
|
||||||
# - Total Bid Value: > €0.00
|
|
||||||
# - Average Bid: > €0.00
|
|
||||||
```
|
|
||||||
|
|
||||||
## Alternative: Quick Fix Without Downtime
|
|
||||||
If you can't afford downtime, delete the corrupted database and let it rebuild:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh tour@192.168.1.149
|
|
||||||
cd /mnt/okcomputer/output
|
|
||||||
mv cache.db cache.db.old
|
|
||||||
docker-compose restart
|
|
||||||
|
|
||||||
# The app will create a new database with correct schema
|
|
||||||
# Wait for scraper to re-populate data (may take 10-15 minutes)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
1. `DatabaseService.java` - Fixed schema definitions (auction_id, lot_id, sale_id now TEXT)
|
|
||||||
2. `fix-schema.sql` - SQL migration script to fix existing database
|
|
||||||
3. `SCHEMA_FIX_INSTRUCTIONS.md` - This file
|
|
||||||
|
|
||||||
## Testing Locally
|
|
||||||
Before deploying to server, test locally:
|
|
||||||
```bash
|
|
||||||
cd C:\vibe\auctiora
|
|
||||||
./mvnw clean test
|
|
||||||
# All tests should pass with new schema
|
|
||||||
```
|
|
||||||
128
fix-schema.sql
128
fix-schema.sql
@@ -1,128 +0,0 @@
|
|||||||
-- Schema Fix Script for Server Database
|
|
||||||
-- This script migrates auction_id and lot_id from BIGINT to TEXT to match scraper format
|
|
||||||
-- The scraper uses TEXT IDs like "A7-40063-2" but DatabaseService.java was creating BIGINT columns
|
|
||||||
|
|
||||||
-- Step 1: Backup existing data
|
|
||||||
CREATE TABLE IF NOT EXISTS auctions_backup AS SELECT * FROM auctions;
|
|
||||||
CREATE TABLE IF NOT EXISTS lots_backup AS SELECT * FROM lots;
|
|
||||||
CREATE TABLE IF NOT EXISTS images_backup AS SELECT * FROM images;
|
|
||||||
|
|
||||||
-- Step 2: Drop existing tables (CASCADE would drop foreign keys)
|
|
||||||
DROP TABLE IF EXISTS images;
|
|
||||||
DROP TABLE IF EXISTS lots;
|
|
||||||
DROP TABLE IF EXISTS auctions;
|
|
||||||
|
|
||||||
-- Step 3: Recreate auctions table with TEXT primary key (matching scraper format)
|
|
||||||
CREATE TABLE auctions (
|
|
||||||
auction_id TEXT PRIMARY KEY,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
location TEXT,
|
|
||||||
city TEXT,
|
|
||||||
country TEXT,
|
|
||||||
url TEXT NOT NULL UNIQUE,
|
|
||||||
type TEXT,
|
|
||||||
lot_count INTEGER DEFAULT 0,
|
|
||||||
closing_time TEXT,
|
|
||||||
discovered_at INTEGER
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Step 4: Recreate lots table with TEXT primary key (matching scraper format)
|
|
||||||
CREATE TABLE lots (
|
|
||||||
lot_id TEXT PRIMARY KEY,
|
|
||||||
sale_id TEXT,
|
|
||||||
auction_id TEXT,
|
|
||||||
title TEXT,
|
|
||||||
description TEXT,
|
|
||||||
manufacturer TEXT,
|
|
||||||
type TEXT,
|
|
||||||
year INTEGER,
|
|
||||||
category TEXT,
|
|
||||||
current_bid REAL,
|
|
||||||
currency TEXT DEFAULT 'EUR',
|
|
||||||
url TEXT UNIQUE,
|
|
||||||
closing_time TEXT,
|
|
||||||
closing_notified INTEGER DEFAULT 0,
|
|
||||||
starting_bid REAL,
|
|
||||||
minimum_bid REAL,
|
|
||||||
status TEXT,
|
|
||||||
brand TEXT,
|
|
||||||
model TEXT,
|
|
||||||
attributes_json TEXT,
|
|
||||||
first_bid_time TEXT,
|
|
||||||
last_bid_time TEXT,
|
|
||||||
bid_velocity REAL,
|
|
||||||
bid_increment REAL,
|
|
||||||
year_manufactured INTEGER,
|
|
||||||
condition_score REAL,
|
|
||||||
condition_description TEXT,
|
|
||||||
serial_number TEXT,
|
|
||||||
damage_description TEXT,
|
|
||||||
followers_count INTEGER DEFAULT 0,
|
|
||||||
estimated_min_price REAL,
|
|
||||||
estimated_max_price REAL,
|
|
||||||
lot_condition TEXT,
|
|
||||||
appearance TEXT,
|
|
||||||
estimated_min REAL,
|
|
||||||
estimated_max REAL,
|
|
||||||
next_bid_step_cents INTEGER,
|
|
||||||
condition TEXT,
|
|
||||||
category_path TEXT,
|
|
||||||
city_location TEXT,
|
|
||||||
country_code TEXT,
|
|
||||||
bidding_status TEXT,
|
|
||||||
packaging TEXT,
|
|
||||||
quantity INTEGER,
|
|
||||||
vat REAL,
|
|
||||||
buyer_premium_percentage REAL,
|
|
||||||
remarks TEXT,
|
|
||||||
reserve_price REAL,
|
|
||||||
reserve_met INTEGER,
|
|
||||||
view_count INTEGER,
|
|
||||||
bid_count INTEGER,
|
|
||||||
viewing_time TEXT,
|
|
||||||
pickup_date TEXT,
|
|
||||||
location TEXT,
|
|
||||||
scraped_at TEXT,
|
|
||||||
FOREIGN KEY (auction_id) REFERENCES auctions(auction_id),
|
|
||||||
FOREIGN KEY (sale_id) REFERENCES auctions(auction_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Step 5: Recreate images table
|
|
||||||
CREATE TABLE images (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
lot_id TEXT,
|
|
||||||
url TEXT,
|
|
||||||
local_path TEXT,
|
|
||||||
labels TEXT,
|
|
||||||
processed_at INTEGER,
|
|
||||||
downloaded INTEGER DEFAULT 0,
|
|
||||||
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Step 6: Create bid_history table if it doesn't exist
|
|
||||||
CREATE TABLE IF NOT EXISTS bid_history (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
lot_id TEXT,
|
|
||||||
bid_amount REAL,
|
|
||||||
bid_time TEXT,
|
|
||||||
is_autobid INTEGER DEFAULT 0,
|
|
||||||
bidder_id TEXT,
|
|
||||||
bidder_number INTEGER,
|
|
||||||
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Step 7: Restore data from backup (converting BIGINT to TEXT if needed)
|
|
||||||
INSERT OR IGNORE INTO auctions SELECT * FROM auctions_backup;
|
|
||||||
INSERT OR IGNORE INTO lots SELECT * FROM lots_backup;
|
|
||||||
INSERT OR IGNORE INTO images SELECT * FROM images_backup;
|
|
||||||
|
|
||||||
-- Step 8: Create indexes for performance
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_lots_sale_id ON lots(sale_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_lots_auction_id ON lots(auction_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_images_lot_id ON images(lot_id);
|
|
||||||
|
|
||||||
-- Step 9: Clean up backup tables (optional - comment out if you want to keep backups)
|
|
||||||
-- DROP TABLE auctions_backup;
|
|
||||||
-- DROP TABLE lots_backup;
|
|
||||||
-- DROP TABLE images_backup;
|
|
||||||
6
pom.xml
6
pom.xml
@@ -175,6 +175,12 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Mockito for mocking in tests -->
|
<!-- Mockito for mocking in tests -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5-mockito</artifactId>
|
||||||
|
<version>3.30.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.mockito</groupId>
|
<groupId>org.mockito</groupId>
|
||||||
<artifactId>mockito-core</artifactId>
|
<artifactId>mockito-core</artifactId>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import java.time.LocalDateTime;
|
|||||||
* Data typically populated by the external scraper process
|
* Data typically populated by the external scraper process
|
||||||
*/
|
*/
|
||||||
public record AuctionInfo(
|
public record AuctionInfo(
|
||||||
long auctionId, // Unique auction ID (from URL)
|
long auctionId, // Unique auction ID (from URL)
|
||||||
String title, // Auction title
|
String title, // Auction title
|
||||||
String location, // Location (e.g., "Amsterdam, NL")
|
String location, // Location (e.g., "Amsterdam, NL")
|
||||||
String city, // City name
|
String city, // City name
|
||||||
String country, // Country code (e.g., "NL")
|
String country, // Country code (e.g., "NL")
|
||||||
String url, // Full auction URL
|
String url, // Full auction URL
|
||||||
String typePrefix, // Auction type (A1 or A7)
|
String typePrefix, // Auction type (A1 or A7)
|
||||||
int lotCount, // Number of lots/kavels
|
int lotCount, // Number of lots/kavels
|
||||||
LocalDateTime firstLotClosingTime // Closing time if available
|
LocalDateTime firstLotClosingTime // Closing time if available
|
||||||
) { }
|
) { }
|
||||||
|
|||||||
@@ -11,93 +11,72 @@ import org.eclipse.microprofile.health.Startup;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
/**
|
|
||||||
* Health checks for Auction Monitor.
|
|
||||||
* Provides liveness and readiness probes for Kubernetes/Docker deployment.
|
|
||||||
*/
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class AuctionMonitorHealthCheck {
|
public class AuctionMonitorHealthCheck {
|
||||||
|
|
||||||
@Inject
|
@Liveness
|
||||||
DatabaseService db;
|
public static class LivenessCheck
|
||||||
|
implements HealthCheck {
|
||||||
/**
|
|
||||||
* Liveness probe - checks if application is alive
|
@Override public HealthCheckResponse call() {
|
||||||
* GET /health/live
|
return HealthCheckResponse.up("Auction Monitor is alive");
|
||||||
*/
|
}
|
||||||
@Liveness
|
}
|
||||||
public static class LivenessCheck implements HealthCheck {
|
|
||||||
@Override
|
@Readiness
|
||||||
public HealthCheckResponse call() {
|
@ApplicationScoped
|
||||||
return HealthCheckResponse.up("Auction Monitor is alive");
|
public static class ReadinessCheck
|
||||||
}
|
implements HealthCheck {
|
||||||
}
|
|
||||||
|
@Inject DatabaseService db;
|
||||||
/**
|
|
||||||
* Readiness probe - checks if application is ready to serve requests
|
@Override
|
||||||
* GET /health/ready
|
public HealthCheckResponse call() {
|
||||||
*/
|
try {
|
||||||
@Readiness
|
var auctions = db.getAllAuctions();
|
||||||
@ApplicationScoped
|
var dbPath = Paths.get("C:\\mnt\\okcomputer\\output\\cache.db");
|
||||||
public static class ReadinessCheck implements HealthCheck {
|
if (!Files.exists(dbPath.getParent())) {
|
||||||
|
return HealthCheckResponse.down("Database directory does not exist");
|
||||||
@Inject
|
|
||||||
DatabaseService db;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public HealthCheckResponse call() {
|
|
||||||
try {
|
|
||||||
// Check database connection
|
|
||||||
var auctions = db.getAllAuctions();
|
|
||||||
|
|
||||||
// Check database path exists
|
|
||||||
var dbPath = Paths.get("C:\\mnt\\okcomputer\\output\\cache.db");
|
|
||||||
if (!Files.exists(dbPath.getParent())) {
|
|
||||||
return HealthCheckResponse.down("Database directory does not exist");
|
|
||||||
}
|
|
||||||
|
|
||||||
return HealthCheckResponse.named("database")
|
|
||||||
.up()
|
|
||||||
.withData("auctions", auctions.size())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
return HealthCheckResponse.named("database")
|
|
||||||
.down()
|
|
||||||
.withData("error", e.getMessage())
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
return HealthCheckResponse.named("database")
|
||||||
|
.up()
|
||||||
/**
|
.withData("auctions", auctions.size())
|
||||||
* Startup probe - checks if application has started correctly
|
.build();
|
||||||
* GET /health/started
|
|
||||||
*/
|
} catch (Exception e) {
|
||||||
@Startup
|
return HealthCheckResponse.named("database")
|
||||||
@ApplicationScoped
|
.down()
|
||||||
public static class StartupCheck implements HealthCheck {
|
.withData("error", e.getMessage())
|
||||||
|
.build();
|
||||||
@Inject
|
}
|
||||||
DatabaseService db;
|
}
|
||||||
|
}
|
||||||
@Override
|
|
||||||
public HealthCheckResponse call() {
|
@Startup
|
||||||
try {
|
@ApplicationScoped
|
||||||
// Verify database schema
|
public static class StartupCheck
|
||||||
db.ensureSchema();
|
implements HealthCheck {
|
||||||
|
|
||||||
return HealthCheckResponse.named("startup")
|
@Inject DatabaseService db;
|
||||||
.up()
|
|
||||||
.withData("message", "Database schema initialized")
|
@Override
|
||||||
.build();
|
public HealthCheckResponse call() {
|
||||||
|
try {
|
||||||
} catch (Exception e) {
|
// Verify database schema
|
||||||
return HealthCheckResponse.named("startup")
|
db.ensureSchema();
|
||||||
.down()
|
|
||||||
.withData("error", e.getMessage())
|
return HealthCheckResponse.named("startup")
|
||||||
.build();
|
.up()
|
||||||
}
|
.withData("message", "Database schema initialized")
|
||||||
}
|
.build();
|
||||||
}
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return HealthCheckResponse.named("startup")
|
||||||
|
.down()
|
||||||
|
.withData("error", e.getMessage())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,58 +19,42 @@ import java.sql.SQLException;
|
|||||||
@Startup
|
@Startup
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class AuctionMonitorProducer {
|
public class AuctionMonitorProducer {
|
||||||
|
|
||||||
private static final Logger LOG = Logger.getLogger(AuctionMonitorProducer.class);
|
private static final Logger LOG = Logger.getLogger(AuctionMonitorProducer.class);
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct void init() {
|
||||||
void init() {
|
try {
|
||||||
// Load OpenCV native library at startup
|
nu.pattern.OpenCV.loadLocally();
|
||||||
try {
|
LOG.info("✓ OpenCV loaded successfully");
|
||||||
nu.pattern.OpenCV.loadLocally();
|
} catch (Exception e) {
|
||||||
LOG.info("✓ OpenCV loaded successfully");
|
LOG.warn("⚠️ OpenCV not available - image detection will be disabled: " + e.getMessage());
|
||||||
} catch (Exception e) {
|
}
|
||||||
LOG.warn("⚠️ OpenCV not available - image detection will be disabled: " + e.getMessage());
|
}
|
||||||
}
|
|
||||||
}
|
@Produces @Singleton public DatabaseService produceDatabaseService(
|
||||||
|
@ConfigProperty(name = "auction.database.path") String dbPath) throws SQLException {
|
||||||
@Produces
|
var db = new DatabaseService(dbPath);
|
||||||
@Singleton
|
db.ensureSchema();
|
||||||
public DatabaseService produceDatabaseService(
|
return db;
|
||||||
@ConfigProperty(name = "auction.database.path") String dbPath) throws SQLException {
|
}
|
||||||
|
|
||||||
LOG.infof("Initializing DatabaseService with path: %s", dbPath);
|
@Produces @Singleton public NotificationService produceNotificationService(
|
||||||
var db = new DatabaseService(dbPath);
|
@ConfigProperty(name = "auction.notification.config") String config) {
|
||||||
db.ensureSchema();
|
|
||||||
return db;
|
return new NotificationService(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces @Singleton public ObjectDetectionService produceObjectDetectionService(
|
||||||
@Singleton
|
@ConfigProperty(name = "auction.yolo.config") String cfgPath,
|
||||||
public NotificationService produceNotificationService(
|
@ConfigProperty(name = "auction.yolo.weights") String weightsPath,
|
||||||
@ConfigProperty(name = "auction.notification.config") String config) {
|
@ConfigProperty(name = "auction.yolo.classes") String classesPath) throws IOException {
|
||||||
|
|
||||||
LOG.infof("Initializing NotificationService with config: %s", config);
|
return new ObjectDetectionService(cfgPath, weightsPath, classesPath);
|
||||||
return new NotificationService(config);
|
}
|
||||||
}
|
|
||||||
|
@Produces @Singleton public ImageProcessingService produceImageProcessingService(
|
||||||
@Produces
|
DatabaseService db,
|
||||||
@Singleton
|
ObjectDetectionService detector) {
|
||||||
public ObjectDetectionService produceObjectDetectionService(
|
return new ImageProcessingService(db, detector);
|
||||||
@ConfigProperty(name = "auction.yolo.config") String cfgPath,
|
}
|
||||||
@ConfigProperty(name = "auction.yolo.weights") String weightsPath,
|
|
||||||
@ConfigProperty(name = "auction.yolo.classes") String classesPath) throws IOException {
|
|
||||||
|
|
||||||
LOG.infof("Initializing ObjectDetectionService");
|
|
||||||
return new ObjectDetectionService(cfgPath, weightsPath, classesPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Produces
|
|
||||||
@Singleton
|
|
||||||
public ImageProcessingService produceImageProcessingService(
|
|
||||||
DatabaseService db,
|
|
||||||
ObjectDetectionService detector) {
|
|
||||||
|
|
||||||
LOG.infof("Initializing ImageProcessingService");
|
|
||||||
return new ImageProcessingService(db, detector);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,21 +30,12 @@ public class AuctionMonitorResource {
|
|||||||
|
|
||||||
private static final Logger LOG = Logger.getLogger(AuctionMonitorResource.class);
|
private static final Logger LOG = Logger.getLogger(AuctionMonitorResource.class);
|
||||||
|
|
||||||
@Inject
|
@Inject DatabaseService db;
|
||||||
DatabaseService db;
|
@Inject QuarkusWorkflowScheduler scheduler;
|
||||||
|
@Inject NotificationService notifier;
|
||||||
|
@Inject RateLimitedHttpClient httpClient;
|
||||||
|
@Inject LotEnrichmentService enrichmentService;
|
||||||
|
|
||||||
@Inject
|
|
||||||
QuarkusWorkflowScheduler scheduler;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
NotificationService notifier;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
RateLimitedHttpClient httpClient;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
LotEnrichmentService enrichmentService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/monitor/status
|
* GET /api/monitor/status
|
||||||
* Returns current monitoring status
|
* Returns current monitoring status
|
||||||
@@ -99,33 +90,33 @@ public class AuctionMonitorResource {
|
|||||||
stats.put("totalImages", db.getImageCount());
|
stats.put("totalImages", db.getImageCount());
|
||||||
|
|
||||||
// Lot statistics
|
// Lot statistics
|
||||||
var activeLots = 0;
|
var activeLots = 0;
|
||||||
var lotsWithBids = 0;
|
var lotsWithBids = 0;
|
||||||
double totalBids = 0;
|
double totalBids = 0;
|
||||||
var hotLots = 0;
|
var hotLots = 0;
|
||||||
var sleeperLots = 0;
|
var sleeperLots = 0;
|
||||||
var bargainLots = 0;
|
var bargainLots = 0;
|
||||||
var lotsClosing1h = 0;
|
var lotsClosing1h = 0;
|
||||||
var lotsClosing6h = 0;
|
var lotsClosing6h = 0;
|
||||||
double totalBidVelocity = 0;
|
double totalBidVelocity = 0;
|
||||||
int velocityCount = 0;
|
int velocityCount = 0;
|
||||||
|
|
||||||
for (var lot : lots) {
|
for (var lot : lots) {
|
||||||
long minutesLeft = lot.closingTime() != null ? lot.minutesUntilClose() : Long.MAX_VALUE;
|
long minutesLeft = lot.closingTime() != null ? lot.minutesUntilClose() : Long.MAX_VALUE;
|
||||||
|
|
||||||
if (lot.closingTime() != null && minutesLeft > 0) {
|
if (lot.closingTime() != null && minutesLeft > 0) {
|
||||||
activeLots++;
|
activeLots++;
|
||||||
|
|
||||||
// Time-based counts
|
// Time-based counts
|
||||||
if (minutesLeft < 60) lotsClosing1h++;
|
if (minutesLeft < 60) lotsClosing1h++;
|
||||||
if (minutesLeft < 360) lotsClosing6h++;
|
if (minutesLeft < 360) lotsClosing6h++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lot.currentBid() > 0) {
|
if (lot.currentBid() > 0) {
|
||||||
lotsWithBids++;
|
lotsWithBids++;
|
||||||
totalBids += lot.currentBid();
|
totalBids += lot.currentBid();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intelligence metrics (require GraphQL enrichment)
|
// Intelligence metrics (require GraphQL enrichment)
|
||||||
if (lot.followersCount() != null && lot.followersCount() > 20) {
|
if (lot.followersCount() != null && lot.followersCount() > 20) {
|
||||||
hotLots++;
|
hotLots++;
|
||||||
@@ -136,22 +127,22 @@ public class AuctionMonitorResource {
|
|||||||
if (lot.isBelowEstimate()) {
|
if (lot.isBelowEstimate()) {
|
||||||
bargainLots++;
|
bargainLots++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bid velocity
|
// Bid velocity
|
||||||
if (lot.bidVelocity() != null && lot.bidVelocity() > 0) {
|
if (lot.bidVelocity() != null && lot.bidVelocity() > 0) {
|
||||||
totalBidVelocity += lot.bidVelocity();
|
totalBidVelocity += lot.bidVelocity();
|
||||||
velocityCount++;
|
velocityCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate bids per hour (average velocity across all lots with velocity data)
|
// Calculate bids per hour (average velocity across all lots with velocity data)
|
||||||
double bidsPerHour = velocityCount > 0 ? totalBidVelocity / velocityCount : 0;
|
double bidsPerHour = velocityCount > 0 ? totalBidVelocity / velocityCount : 0;
|
||||||
|
|
||||||
stats.put("activeLots", activeLots);
|
stats.put("activeLots", activeLots);
|
||||||
stats.put("lotsWithBids", lotsWithBids);
|
stats.put("lotsWithBids", lotsWithBids);
|
||||||
stats.put("totalBidValue", String.format("€%.2f", totalBids));
|
stats.put("totalBidValue", String.format("€%.2f", totalBids));
|
||||||
stats.put("averageBid", lotsWithBids > 0 ? String.format("€%.2f", totalBids / lotsWithBids) : "€0.00");
|
stats.put("averageBid", lotsWithBids > 0 ? String.format("€%.2f", totalBids / lotsWithBids) : "€0.00");
|
||||||
|
|
||||||
// Bidding intelligence
|
// Bidding intelligence
|
||||||
stats.put("bidsPerHour", String.format("%.1f", bidsPerHour));
|
stats.put("bidsPerHour", String.format("%.1f", bidsPerHour));
|
||||||
stats.put("hotLots", hotLots);
|
stats.put("hotLots", hotLots);
|
||||||
@@ -159,11 +150,11 @@ public class AuctionMonitorResource {
|
|||||||
stats.put("bargainLots", bargainLots);
|
stats.put("bargainLots", bargainLots);
|
||||||
stats.put("lotsClosing1h", lotsClosing1h);
|
stats.put("lotsClosing1h", lotsClosing1h);
|
||||||
stats.put("lotsClosing6h", lotsClosing6h);
|
stats.put("lotsClosing6h", lotsClosing6h);
|
||||||
|
|
||||||
// Conversion rate
|
// Conversion rate
|
||||||
double conversionRate = activeLots > 0 ? (lotsWithBids * 100.0 / activeLots) : 0;
|
double conversionRate = activeLots > 0 ? (lotsWithBids * 100.0 / activeLots) : 0;
|
||||||
stats.put("conversionRate", String.format("%.1f%%", conversionRate));
|
stats.put("conversionRate", String.format("%.1f%%", conversionRate));
|
||||||
|
|
||||||
return Response.ok(stats).build();
|
return Response.ok(stats).build();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -184,12 +175,12 @@ public class AuctionMonitorResource {
|
|||||||
try {
|
try {
|
||||||
var lots = db.getAllLots();
|
var lots = db.getAllLots();
|
||||||
var closingSoon = lots.stream()
|
var closingSoon = lots.stream()
|
||||||
.filter(lot -> lot.closingTime() != null)
|
.filter(lot -> lot.closingTime() != null)
|
||||||
.filter(lot -> lot.minutesUntilClose() > 0 && lot.minutesUntilClose() <= hours * 60)
|
.filter(lot -> lot.minutesUntilClose() > 0 && lot.minutesUntilClose() <= hours * 60)
|
||||||
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
|
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
|
||||||
.limit(100)
|
.limit(100)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return Response.ok(closingSoon).build();
|
return Response.ok(closingSoon).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get closing soon lots", e);
|
LOG.error("Failed to get closing soon lots", e);
|
||||||
@@ -198,7 +189,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/monitor/lots/{lotId}/bid-history
|
* GET /api/monitor/lots/{lotId}/bid-history
|
||||||
* Returns bid history for a specific lot
|
* Returns bid history for a specific lot
|
||||||
@@ -216,7 +207,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/monitor/trigger/scraper-import
|
* POST /api/monitor/trigger/scraper-import
|
||||||
* Manually trigger scraper import workflow
|
* Manually trigger scraper import workflow
|
||||||
@@ -288,7 +279,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/monitor/trigger/graphql-enrichment
|
* POST /api/monitor/trigger/graphql-enrichment
|
||||||
* Manually trigger GraphQL enrichment for all lots or lots closing soon
|
* Manually trigger GraphQL enrichment for all lots or lots closing soon
|
||||||
@@ -301,15 +292,15 @@ public class AuctionMonitorResource {
|
|||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
enriched = enrichmentService.enrichClosingSoonLots(hours);
|
enriched = enrichmentService.enrichClosingSoonLots(hours);
|
||||||
return Response.ok(Map.of(
|
return Response.ok(Map.of(
|
||||||
"message", "GraphQL enrichment triggered for lots closing within " + hours + " hours",
|
"message", "GraphQL enrichment triggered for lots closing within " + hours + " hours",
|
||||||
"enrichedCount", enriched
|
"enrichedCount", enriched
|
||||||
)).build();
|
)).build();
|
||||||
} else {
|
} else {
|
||||||
enriched = enrichmentService.enrichAllActiveLots();
|
enriched = enrichmentService.enrichAllActiveLots();
|
||||||
return Response.ok(Map.of(
|
return Response.ok(Map.of(
|
||||||
"message", "GraphQL enrichment triggered for all lots",
|
"message", "GraphQL enrichment triggered for all lots",
|
||||||
"enrichedCount", enriched
|
"enrichedCount", enriched
|
||||||
)).build();
|
)).build();
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to trigger GraphQL enrichment", e);
|
LOG.error("Failed to trigger GraphQL enrichment", e);
|
||||||
@@ -318,7 +309,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/monitor/auctions
|
* GET /api/monitor/auctions
|
||||||
* Returns list of all auctions
|
* Returns list of all auctions
|
||||||
@@ -375,7 +366,7 @@ public class AuctionMonitorResource {
|
|||||||
})
|
})
|
||||||
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
|
.sorted((a, b) -> Long.compare(a.minutesUntilClose(), b.minutesUntilClose()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return Response.ok(closingSoon).build();
|
return Response.ok(closingSoon).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get closing lots", e);
|
LOG.error("Failed to get closing lots", e);
|
||||||
@@ -530,7 +521,7 @@ public class AuctionMonitorResource {
|
|||||||
public Response getCategoryDistribution() {
|
public Response getCategoryDistribution() {
|
||||||
try {
|
try {
|
||||||
var lots = db.getAllLots();
|
var lots = db.getAllLots();
|
||||||
|
|
||||||
// Category distribution
|
// Category distribution
|
||||||
Map<String, Long> distribution = lots.stream()
|
Map<String, Long> distribution = lots.stream()
|
||||||
.filter(l -> l.category() != null && !l.category().isEmpty())
|
.filter(l -> l.category() != null && !l.category().isEmpty())
|
||||||
@@ -538,34 +529,34 @@ public class AuctionMonitorResource {
|
|||||||
l -> l.category().length() > 20 ? l.category().substring(0, 20) + "..." : l.category(),
|
l -> l.category().length() > 20 ? l.category().substring(0, 20) + "..." : l.category(),
|
||||||
Collectors.counting()
|
Collectors.counting()
|
||||||
));
|
));
|
||||||
|
|
||||||
// Find top category by count
|
// Find top category by count
|
||||||
var topCategory = distribution.entrySet().stream()
|
var topCategory = distribution.entrySet().stream()
|
||||||
.max(Map.Entry.comparingByValue())
|
.max(Map.Entry.comparingByValue())
|
||||||
.map(Map.Entry::getKey)
|
.map(Map.Entry::getKey)
|
||||||
.orElse("N/A");
|
.orElse("N/A");
|
||||||
|
|
||||||
// Calculate average bids per category
|
// Calculate average bids per category
|
||||||
Map<String, Double> avgBidsByCategory = lots.stream()
|
Map<String, Double> avgBidsByCategory = lots.stream()
|
||||||
.filter(l -> l.category() != null && !l.category().isEmpty() && l.currentBid() > 0)
|
.filter(l -> l.category() != null && !l.category().isEmpty() && l.currentBid() > 0)
|
||||||
.collect(Collectors.groupingBy(
|
.collect(Collectors.groupingBy(
|
||||||
l -> l.category().length() > 20 ? l.category().substring(0, 20) + "..." : l.category(),
|
l -> l.category().length() > 20 ? l.category().substring(0, 20) + "..." : l.category(),
|
||||||
Collectors.averagingDouble(Lot::currentBid)
|
Collectors.averagingDouble(Lot::currentBid)
|
||||||
));
|
));
|
||||||
|
|
||||||
double overallAvgBid = lots.stream()
|
double overallAvgBid = lots.stream()
|
||||||
.filter(l -> l.currentBid() > 0)
|
.filter(l -> l.currentBid() > 0)
|
||||||
.mapToDouble(Lot::currentBid)
|
.mapToDouble(Lot::currentBid)
|
||||||
.average()
|
.average()
|
||||||
.orElse(0.0);
|
.orElse(0.0);
|
||||||
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("distribution", distribution);
|
response.put("distribution", distribution);
|
||||||
response.put("topCategory", topCategory);
|
response.put("topCategory", topCategory);
|
||||||
response.put("categoryCount", distribution.size());
|
response.put("categoryCount", distribution.size());
|
||||||
response.put("averageBidOverall", String.format("€%.2f", overallAvgBid));
|
response.put("averageBidOverall", String.format("€%.2f", overallAvgBid));
|
||||||
response.put("avgBidsByCategory", avgBidsByCategory);
|
response.put("avgBidsByCategory", avgBidsByCategory);
|
||||||
|
|
||||||
return Response.ok(response).build();
|
return Response.ok(response).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get category distribution", e);
|
LOG.error("Failed to get category distribution", e);
|
||||||
@@ -663,7 +654,7 @@ public class AuctionMonitorResource {
|
|||||||
.max(Map.Entry.comparingByValue())
|
.max(Map.Entry.comparingByValue())
|
||||||
.map(Map.Entry::getKey)
|
.map(Map.Entry::getKey)
|
||||||
.orElse("N/A");
|
.orElse("N/A");
|
||||||
|
|
||||||
if (!"N/A".equals(topCountry)) {
|
if (!"N/A".equals(topCountry)) {
|
||||||
insights.add(Map.of(
|
insights.add(Map.of(
|
||||||
"icon", "fa-globe",
|
"icon", "fa-globe",
|
||||||
@@ -671,7 +662,7 @@ public class AuctionMonitorResource {
|
|||||||
"description", "Top performing country"
|
"description", "Top performing country"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add sleeper lots insight
|
// Add sleeper lots insight
|
||||||
long sleeperCount = lots.stream().filter(Lot::isSleeperLot).count();
|
long sleeperCount = lots.stream().filter(Lot::isSleeperLot).count();
|
||||||
if (sleeperCount > 0) {
|
if (sleeperCount > 0) {
|
||||||
@@ -681,7 +672,7 @@ public class AuctionMonitorResource {
|
|||||||
"description", "High interest, low bids - opportunity?"
|
"description", "High interest, low bids - opportunity?"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add bargain insight
|
// Add bargain insight
|
||||||
long bargainCount = lots.stream().filter(Lot::isBelowEstimate).count();
|
long bargainCount = lots.stream().filter(Lot::isBelowEstimate).count();
|
||||||
if (bargainCount > 5) {
|
if (bargainCount > 5) {
|
||||||
@@ -691,7 +682,7 @@ public class AuctionMonitorResource {
|
|||||||
"description", "Priced below auction house estimates"
|
"description", "Priced below auction house estimates"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add watch/followers insight
|
// Add watch/followers insight
|
||||||
long highWatchCount = lots.stream()
|
long highWatchCount = lots.stream()
|
||||||
.filter(l -> l.followersCount() != null && l.followersCount() > 20)
|
.filter(l -> l.followersCount() != null && l.followersCount() > 20)
|
||||||
@@ -703,7 +694,7 @@ public class AuctionMonitorResource {
|
|||||||
"description", "High follower count, strong competition"
|
"description", "High follower count, strong competition"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.ok(insights).build();
|
return Response.ok(insights).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get insights", e);
|
LOG.error("Failed to get insights", e);
|
||||||
@@ -725,12 +716,12 @@ public class AuctionMonitorResource {
|
|||||||
var sleepers = allLots.stream()
|
var sleepers = allLots.stream()
|
||||||
.filter(Lot::isSleeperLot)
|
.filter(Lot::isSleeperLot)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<String, Object> response = Map.of(
|
Map<String, Object> response = Map.of(
|
||||||
"count", sleepers.size(),
|
"count", sleepers.size(),
|
||||||
"lots", sleepers
|
"lots", sleepers
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.ok(response).build();
|
return Response.ok(response).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get sleeper lots", e);
|
LOG.error("Failed to get sleeper lots", e);
|
||||||
@@ -739,7 +730,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/monitor/intelligence/bargains
|
* GET /api/monitor/intelligence/bargains
|
||||||
* Returns lots priced below auction house estimates
|
* Returns lots priced below auction house estimates
|
||||||
@@ -759,12 +750,12 @@ public class AuctionMonitorResource {
|
|||||||
return ratioA.compareTo(ratioB);
|
return ratioA.compareTo(ratioB);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<String, Object> response = Map.of(
|
Map<String, Object> response = Map.of(
|
||||||
"count", bargains.size(),
|
"count", bargains.size(),
|
||||||
"lots", bargains
|
"lots", bargains
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.ok(response).build();
|
return Response.ok(response).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get bargains", e);
|
LOG.error("Failed to get bargains", e);
|
||||||
@@ -773,7 +764,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/monitor/intelligence/popular
|
* GET /api/monitor/intelligence/popular
|
||||||
* Returns lots by popularity level
|
* Returns lots by popularity level
|
||||||
@@ -791,13 +782,13 @@ public class AuctionMonitorResource {
|
|||||||
return followersB.compareTo(followersA);
|
return followersB.compareTo(followersA);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<String, Object> response = Map.of(
|
Map<String, Object> response = Map.of(
|
||||||
"count", popular.size(),
|
"count", popular.size(),
|
||||||
"level", level,
|
"level", level,
|
||||||
"lots", popular
|
"lots", popular
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.ok(response).build();
|
return Response.ok(response).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get popular lots", e);
|
LOG.error("Failed to get popular lots", e);
|
||||||
@@ -806,7 +797,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/monitor/intelligence/price-analysis
|
* GET /api/monitor/intelligence/price-analysis
|
||||||
* Returns price vs estimate analysis
|
* Returns price vs estimate analysis
|
||||||
@@ -816,28 +807,28 @@ public class AuctionMonitorResource {
|
|||||||
public Response getPriceAnalysis() {
|
public Response getPriceAnalysis() {
|
||||||
try {
|
try {
|
||||||
var allLots = db.getAllLots();
|
var allLots = db.getAllLots();
|
||||||
|
|
||||||
long belowEstimate = allLots.stream().filter(Lot::isBelowEstimate).count();
|
long belowEstimate = allLots.stream().filter(Lot::isBelowEstimate).count();
|
||||||
long aboveEstimate = allLots.stream().filter(Lot::isAboveEstimate).count();
|
long aboveEstimate = allLots.stream().filter(Lot::isAboveEstimate).count();
|
||||||
long withEstimates = allLots.stream()
|
long withEstimates = allLots.stream()
|
||||||
.filter(lot -> lot.estimatedMin() != null && lot.estimatedMax() != null)
|
.filter(lot -> lot.estimatedMin() != null && lot.estimatedMax() != null)
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
double avgPriceVsEstimate = allLots.stream()
|
double avgPriceVsEstimate = allLots.stream()
|
||||||
.map(Lot::getPriceVsEstimateRatio)
|
.map(Lot::getPriceVsEstimateRatio)
|
||||||
.filter(ratio -> ratio != null)
|
.filter(ratio -> ratio != null)
|
||||||
.mapToDouble(Double::doubleValue)
|
.mapToDouble(Double::doubleValue)
|
||||||
.average()
|
.average()
|
||||||
.orElse(0.0);
|
.orElse(0.0);
|
||||||
|
|
||||||
Map<String, Object> response = Map.of(
|
Map<String, Object> response = Map.of(
|
||||||
"totalLotsWithEstimates", withEstimates,
|
"totalLotsWithEstimates", withEstimates,
|
||||||
"belowEstimate", belowEstimate,
|
"belowEstimate", belowEstimate,
|
||||||
"aboveEstimate", aboveEstimate,
|
"aboveEstimate", aboveEstimate,
|
||||||
"averagePriceVsEstimatePercent", Math.round(avgPriceVsEstimate),
|
"averagePriceVsEstimatePercent", Math.round(avgPriceVsEstimate),
|
||||||
"bargainOpportunities", belowEstimate
|
"bargainOpportunities", belowEstimate
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.ok(response).build();
|
return Response.ok(response).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get price analysis", e);
|
LOG.error("Failed to get price analysis", e);
|
||||||
@@ -846,7 +837,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/monitor/lots/{lotId}/intelligence
|
* GET /api/monitor/lots/{lotId}/intelligence
|
||||||
* Returns detailed intelligence for a specific lot
|
* Returns detailed intelligence for a specific lot
|
||||||
@@ -859,13 +850,13 @@ public class AuctionMonitorResource {
|
|||||||
.filter(l -> l.lotId() == lotId)
|
.filter(l -> l.lotId() == lotId)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (lot == null) {
|
if (lot == null) {
|
||||||
return Response.status(Response.Status.NOT_FOUND)
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
.entity(Map.of("error", "Lot not found"))
|
.entity(Map.of("error", "Lot not found"))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> intelligence = new HashMap<>();
|
Map<String, Object> intelligence = new HashMap<>();
|
||||||
intelligence.put("lotId", lot.lotId());
|
intelligence.put("lotId", lot.lotId());
|
||||||
intelligence.put("followersCount", lot.followersCount());
|
intelligence.put("followersCount", lot.followersCount());
|
||||||
@@ -882,7 +873,7 @@ public class AuctionMonitorResource {
|
|||||||
intelligence.put("condition", lot.condition());
|
intelligence.put("condition", lot.condition());
|
||||||
intelligence.put("vat", lot.vat());
|
intelligence.put("vat", lot.vat());
|
||||||
intelligence.put("buyerPremium", lot.buyerPremiumPercentage());
|
intelligence.put("buyerPremium", lot.buyerPremiumPercentage());
|
||||||
|
|
||||||
return Response.ok(intelligence).build();
|
return Response.ok(intelligence).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get lot intelligence", e);
|
LOG.error("Failed to get lot intelligence", e);
|
||||||
@@ -891,7 +882,7 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/monitor/charts/watch-distribution
|
* GET /api/monitor/charts/watch-distribution
|
||||||
* Returns follower/watch count distribution
|
* Returns follower/watch count distribution
|
||||||
@@ -901,14 +892,14 @@ public class AuctionMonitorResource {
|
|||||||
public Response getWatchDistribution() {
|
public Response getWatchDistribution() {
|
||||||
try {
|
try {
|
||||||
var lots = db.getAllLots();
|
var lots = db.getAllLots();
|
||||||
|
|
||||||
Map<String, Long> distribution = new HashMap<>();
|
Map<String, Long> distribution = new HashMap<>();
|
||||||
distribution.put("0 watchers", lots.stream().filter(l -> l.followersCount() == null || l.followersCount() == 0).count());
|
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("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("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("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());
|
distribution.put("50+ watchers", lots.stream().filter(l -> l.followersCount() != null && l.followersCount() > 50).count());
|
||||||
|
|
||||||
return Response.ok(distribution).build();
|
return Response.ok(distribution).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to get watch distribution", e);
|
LOG.error("Failed to get watch distribution", e);
|
||||||
@@ -917,14 +908,14 @@ public class AuctionMonitorResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper class for trend data
|
// Helper class for trend data
|
||||||
public static class TrendHour {
|
public static class TrendHour {
|
||||||
|
|
||||||
public int hour;
|
public int hour;
|
||||||
public int lots;
|
public int lots;
|
||||||
public int bids;
|
public int bids;
|
||||||
|
|
||||||
public TrendHour(int hour, int lots, int bids) {
|
public TrendHour(int hour, int lots, int bids) {
|
||||||
this.hour = hour;
|
this.hour = hour;
|
||||||
this.lots = lots;
|
this.lots = lots;
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class DatabaseService {
|
public class DatabaseService {
|
||||||
|
|
||||||
private final String url;
|
private final String url;
|
||||||
|
|
||||||
DatabaseService(String dbPath) {
|
DatabaseService(String dbPath) {
|
||||||
// Enable WAL mode and busy timeout for concurrent access
|
// Enable WAL mode and busy timeout for concurrent access
|
||||||
this.url = "jdbc:sqlite:" + dbPath + "?journal_mode=WAL&busy_timeout=10000";
|
this.url = "jdbc:sqlite:" + dbPath + "?journal_mode=WAL&busy_timeout=10000";
|
||||||
@@ -38,7 +38,7 @@ public class DatabaseService {
|
|||||||
stmt.execute("PRAGMA journal_mode=WAL");
|
stmt.execute("PRAGMA journal_mode=WAL");
|
||||||
stmt.execute("PRAGMA busy_timeout=10000");
|
stmt.execute("PRAGMA busy_timeout=10000");
|
||||||
stmt.execute("PRAGMA synchronous=NORMAL");
|
stmt.execute("PRAGMA synchronous=NORMAL");
|
||||||
|
|
||||||
// Cache table (for HTTP caching)
|
// Cache table (for HTTP caching)
|
||||||
stmt.execute("""
|
stmt.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS cache (
|
CREATE TABLE IF NOT EXISTS cache (
|
||||||
@@ -47,7 +47,7 @@ public class DatabaseService {
|
|||||||
timestamp REAL,
|
timestamp REAL,
|
||||||
status_code INTEGER
|
status_code INTEGER
|
||||||
)""");
|
)""");
|
||||||
|
|
||||||
// Auctions table (populated by external scraper)
|
// Auctions table (populated by external scraper)
|
||||||
stmt.execute("""
|
stmt.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS auctions (
|
CREATE TABLE IF NOT EXISTS auctions (
|
||||||
@@ -65,7 +65,7 @@ public class DatabaseService {
|
|||||||
closing_time TEXT,
|
closing_time TEXT,
|
||||||
discovered_at INTEGER
|
discovered_at INTEGER
|
||||||
)""");
|
)""");
|
||||||
|
|
||||||
// Lots table (populated by external scraper)
|
// Lots table (populated by external scraper)
|
||||||
stmt.execute("""
|
stmt.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS lots (
|
CREATE TABLE IF NOT EXISTS lots (
|
||||||
@@ -126,7 +126,7 @@ public class DatabaseService {
|
|||||||
view_count INTEGER,
|
view_count INTEGER,
|
||||||
FOREIGN KEY (auction_id) REFERENCES auctions(auction_id)
|
FOREIGN KEY (auction_id) REFERENCES auctions(auction_id)
|
||||||
)""");
|
)""");
|
||||||
|
|
||||||
// Images table (populated by external scraper with URLs and local_path)
|
// Images table (populated by external scraper with URLs and local_path)
|
||||||
stmt.execute("""
|
stmt.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS images (
|
CREATE TABLE IF NOT EXISTS images (
|
||||||
@@ -139,7 +139,7 @@ public class DatabaseService {
|
|||||||
processed_at INTEGER,
|
processed_at INTEGER,
|
||||||
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
||||||
)""");
|
)""");
|
||||||
|
|
||||||
// Bid history table
|
// Bid history table
|
||||||
stmt.execute("""
|
stmt.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS bid_history (
|
CREATE TABLE IF NOT EXISTS bid_history (
|
||||||
@@ -153,7 +153,7 @@ public class DatabaseService {
|
|||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
|
||||||
)""");
|
)""");
|
||||||
|
|
||||||
// Indexes for performance
|
// Indexes for performance
|
||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON cache(timestamp)");
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON cache(timestamp)");
|
||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country)");
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country)");
|
||||||
@@ -164,9 +164,8 @@ public class DatabaseService {
|
|||||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_bidder ON bid_history(bidder_id)");
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_bid_history_bidder ON bid_history(bidder_id)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Inserts or updates an auction record (typically called by external scraper)
|
* Inserts or updates an auction record (typically called by external scraper)
|
||||||
* Handles both auction_id conflicts and url uniqueness constraints
|
* Handles both auction_id conflicts and url uniqueness constraints
|
||||||
*/
|
*/
|
||||||
@@ -185,7 +184,7 @@ public class DatabaseService {
|
|||||||
lot_count = excluded.lot_count,
|
lot_count = excluded.lot_count,
|
||||||
closing_time = excluded.closing_time
|
closing_time = excluded.closing_time
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url)) {
|
try (var conn = DriverManager.getConnection(url)) {
|
||||||
try (var ps = conn.prepareStatement(insertSql)) {
|
try (var ps = conn.prepareStatement(insertSql)) {
|
||||||
ps.setLong(1, auction.auctionId());
|
ps.setLong(1, auction.auctionId());
|
||||||
@@ -205,7 +204,7 @@ public class DatabaseService {
|
|||||||
if (errMsg.contains("UNIQUE constraint failed: auctions.auction_id") ||
|
if (errMsg.contains("UNIQUE constraint failed: auctions.auction_id") ||
|
||||||
errMsg.contains("UNIQUE constraint failed: auctions.url") ||
|
errMsg.contains("UNIQUE constraint failed: auctions.url") ||
|
||||||
errMsg.contains("PRIMARY KEY constraint failed")) {
|
errMsg.contains("PRIMARY KEY constraint failed")) {
|
||||||
|
|
||||||
// Try updating by URL as fallback (most reliable unique identifier)
|
// Try updating by URL as fallback (most reliable unique identifier)
|
||||||
var updateByUrlSql = """
|
var updateByUrlSql = """
|
||||||
UPDATE auctions SET
|
UPDATE auctions SET
|
||||||
@@ -229,12 +228,12 @@ public class DatabaseService {
|
|||||||
ps.setInt(7, auction.lotCount());
|
ps.setInt(7, auction.lotCount());
|
||||||
ps.setString(8, auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null);
|
ps.setString(8, auction.firstLotClosingTime() != null ? auction.firstLotClosingTime().toString() : null);
|
||||||
ps.setString(9, auction.url());
|
ps.setString(9, auction.url());
|
||||||
|
|
||||||
int updated = ps.executeUpdate();
|
int updated = ps.executeUpdate();
|
||||||
if (updated == 0) {
|
if (updated == 0) {
|
||||||
// Auction doesn't exist by URL either - this is unexpected
|
// Auction doesn't exist by URL either - this is unexpected
|
||||||
log.warn("Could not insert or update auction with url={}, auction_id={} - constraint violation but no existing record found",
|
log.warn("Could not insert or update auction with url={}, auction_id={} - constraint violation but no existing record found",
|
||||||
auction.url(), auction.auctionId());
|
auction.url(), auction.auctionId());
|
||||||
} else {
|
} else {
|
||||||
log.debug("Updated existing auction by URL: {}", auction.url());
|
log.debug("Updated existing auction by URL: {}", auction.url());
|
||||||
}
|
}
|
||||||
@@ -255,12 +254,12 @@ public class DatabaseService {
|
|||||||
synchronized List<AuctionInfo> getAllAuctions() throws SQLException {
|
synchronized List<AuctionInfo> getAllAuctions() throws SQLException {
|
||||||
List<AuctionInfo> auctions = new ArrayList<>();
|
List<AuctionInfo> auctions = new ArrayList<>();
|
||||||
var sql = "SELECT auction_id, title, location, city, country, url, type, lot_count, closing_time FROM auctions";
|
var sql = "SELECT auction_id, title, location, city, country, url, type, lot_count, closing_time FROM auctions";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
||||||
var rs = stmt.executeQuery(sql);
|
var rs = stmt.executeQuery(sql);
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
var closingStr = rs.getString("closing_time");
|
var closingStr = rs.getString("closing_time");
|
||||||
LocalDateTime closing = null;
|
LocalDateTime closing = null;
|
||||||
if (closingStr != null && !closingStr.isBlank()) {
|
if (closingStr != null && !closingStr.isBlank()) {
|
||||||
try {
|
try {
|
||||||
closing = LocalDateTime.parse(closingStr);
|
closing = LocalDateTime.parse(closingStr);
|
||||||
@@ -268,7 +267,7 @@ public class DatabaseService {
|
|||||||
log.debug("Invalid closing_time format for auction {}: {}", rs.getLong("auction_id"), closingStr);
|
log.debug("Invalid closing_time format for auction {}: {}", rs.getLong("auction_id"), closingStr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auctions.add(new AuctionInfo(
|
auctions.add(new AuctionInfo(
|
||||||
rs.getLong("auction_id"),
|
rs.getLong("auction_id"),
|
||||||
rs.getString("title"),
|
rs.getString("title"),
|
||||||
@@ -292,13 +291,13 @@ public class DatabaseService {
|
|||||||
List<AuctionInfo> auctions = new ArrayList<>();
|
List<AuctionInfo> auctions = new ArrayList<>();
|
||||||
var sql = "SELECT auction_id, title, location, city, country, url, type, lot_count, closing_time "
|
var sql = "SELECT auction_id, title, location, city, country, url, type, lot_count, closing_time "
|
||||||
+ "FROM auctions WHERE country = ?";
|
+ "FROM auctions WHERE country = ?";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
|
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
|
||||||
ps.setString(1, countryCode);
|
ps.setString(1, countryCode);
|
||||||
var rs = ps.executeQuery();
|
var rs = ps.executeQuery();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
var closingStr = rs.getString("closing_time");
|
var closingStr = rs.getString("closing_time");
|
||||||
LocalDateTime closing = null;
|
LocalDateTime closing = null;
|
||||||
if (closingStr != null && !closingStr.isBlank()) {
|
if (closingStr != null && !closingStr.isBlank()) {
|
||||||
try {
|
try {
|
||||||
closing = LocalDateTime.parse(closingStr);
|
closing = LocalDateTime.parse(closingStr);
|
||||||
@@ -306,7 +305,7 @@ public class DatabaseService {
|
|||||||
log.debug("Invalid closing_time format for auction {}: {}", rs.getLong("auction_id"), closingStr);
|
log.debug("Invalid closing_time format for auction {}: {}", rs.getLong("auction_id"), closingStr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auctions.add(new AuctionInfo(
|
auctions.add(new AuctionInfo(
|
||||||
rs.getLong("auction_id"),
|
rs.getLong("auction_id"),
|
||||||
rs.getString("title"),
|
rs.getString("title"),
|
||||||
@@ -343,16 +342,16 @@ public class DatabaseService {
|
|||||||
closing_time = ?
|
closing_time = ?
|
||||||
WHERE lot_id = ?
|
WHERE lot_id = ?
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var insertSql = """
|
var insertSql = """
|
||||||
INSERT OR IGNORE INTO lots (lot_id, sale_id, title, description, manufacturer, type, year, category, current_bid, currency, url, closing_time, closing_notified)
|
INSERT OR IGNORE INTO lots (lot_id, sale_id, title, description, manufacturer, type, year, category, current_bid, currency, url, closing_time, closing_notified)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url)) {
|
try (var conn = DriverManager.getConnection(url)) {
|
||||||
// Try UPDATE first
|
// Try UPDATE first
|
||||||
try (var ps = conn.prepareStatement(updateSql)) {
|
try (var ps = conn.prepareStatement(updateSql)) {
|
||||||
ps.setLong(1, lot.saleId());
|
ps.setString(1, String.valueOf(lot.saleId()));
|
||||||
ps.setString(2, lot.title());
|
ps.setString(2, lot.title());
|
||||||
ps.setString(3, lot.description());
|
ps.setString(3, lot.description());
|
||||||
ps.setString(4, lot.manufacturer());
|
ps.setString(4, lot.manufacturer());
|
||||||
@@ -363,18 +362,18 @@ public class DatabaseService {
|
|||||||
ps.setString(9, lot.currency());
|
ps.setString(9, lot.currency());
|
||||||
ps.setString(10, lot.url());
|
ps.setString(10, lot.url());
|
||||||
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
|
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
|
||||||
ps.setLong(12, lot.lotId());
|
ps.setString(12, String.valueOf(lot.lotId()));
|
||||||
|
|
||||||
int updated = ps.executeUpdate();
|
int updated = ps.executeUpdate();
|
||||||
if (updated > 0) {
|
if (updated > 0) {
|
||||||
return; // Successfully updated existing record
|
return; // Successfully updated existing record
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no rows updated, try INSERT (ignore if conflicts with UNIQUE constraints)
|
// If no rows updated, try INSERT (ignore if conflicts with UNIQUE constraints)
|
||||||
try (var ps = conn.prepareStatement(insertSql)) {
|
try (var ps = conn.prepareStatement(insertSql)) {
|
||||||
ps.setLong(1, lot.lotId());
|
ps.setString(1, String.valueOf(lot.lotId()));
|
||||||
ps.setLong(2, lot.saleId());
|
ps.setString(2, String.valueOf(lot.saleId()));
|
||||||
ps.setString(3, lot.title());
|
ps.setString(3, lot.title());
|
||||||
ps.setString(4, lot.description());
|
ps.setString(4, lot.description());
|
||||||
ps.setString(5, lot.manufacturer());
|
ps.setString(5, lot.manufacturer());
|
||||||
@@ -390,7 +389,7 @@ public class DatabaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a lot with full intelligence data from GraphQL enrichment.
|
* Updates a lot with full intelligence data from GraphQL enrichment.
|
||||||
* This is a comprehensive update that includes all 24 intelligence fields.
|
* This is a comprehensive update that includes all 24 intelligence fields.
|
||||||
@@ -434,7 +433,7 @@ public class DatabaseService {
|
|||||||
bid_velocity = ?
|
bid_velocity = ?
|
||||||
WHERE lot_id = ?
|
WHERE lot_id = ?
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
|
try (var conn = DriverManager.getConnection(url); var ps = conn.prepareStatement(sql)) {
|
||||||
ps.setLong(1, lot.saleId());
|
ps.setLong(1, lot.saleId());
|
||||||
ps.setString(2, lot.title());
|
ps.setString(2, lot.title());
|
||||||
@@ -447,12 +446,16 @@ public class DatabaseService {
|
|||||||
ps.setString(9, lot.currency());
|
ps.setString(9, lot.currency());
|
||||||
ps.setString(10, lot.url());
|
ps.setString(10, lot.url());
|
||||||
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
|
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
|
||||||
|
|
||||||
// Intelligence fields
|
// Intelligence fields
|
||||||
if (lot.followersCount() != null) ps.setInt(12, lot.followersCount()); else ps.setNull(12, java.sql.Types.INTEGER);
|
if (lot.followersCount() != null) ps.setInt(12, lot.followersCount());
|
||||||
if (lot.estimatedMin() != null) ps.setDouble(13, lot.estimatedMin()); else ps.setNull(13, java.sql.Types.REAL);
|
else ps.setNull(12, java.sql.Types.INTEGER);
|
||||||
if (lot.estimatedMax() != null) ps.setDouble(14, lot.estimatedMax()); else ps.setNull(14, java.sql.Types.REAL);
|
if (lot.estimatedMin() != null) ps.setDouble(13, lot.estimatedMin());
|
||||||
if (lot.nextBidStepInCents() != null) ps.setLong(15, lot.nextBidStepInCents()); else ps.setNull(15, java.sql.Types.BIGINT);
|
else ps.setNull(13, java.sql.Types.REAL);
|
||||||
|
if (lot.estimatedMax() != null) ps.setDouble(14, lot.estimatedMax());
|
||||||
|
else ps.setNull(14, java.sql.Types.REAL);
|
||||||
|
if (lot.nextBidStepInCents() != null) ps.setLong(15, lot.nextBidStepInCents());
|
||||||
|
else ps.setNull(15, java.sql.Types.BIGINT);
|
||||||
ps.setString(16, lot.condition());
|
ps.setString(16, lot.condition());
|
||||||
ps.setString(17, lot.categoryPath());
|
ps.setString(17, lot.categoryPath());
|
||||||
ps.setString(18, lot.cityLocation());
|
ps.setString(18, lot.cityLocation());
|
||||||
@@ -460,28 +463,37 @@ public class DatabaseService {
|
|||||||
ps.setString(20, lot.biddingStatus());
|
ps.setString(20, lot.biddingStatus());
|
||||||
ps.setString(21, lot.appearance());
|
ps.setString(21, lot.appearance());
|
||||||
ps.setString(22, lot.packaging());
|
ps.setString(22, lot.packaging());
|
||||||
if (lot.quantity() != null) ps.setLong(23, lot.quantity()); else ps.setNull(23, java.sql.Types.BIGINT);
|
if (lot.quantity() != null) ps.setLong(23, lot.quantity());
|
||||||
if (lot.vat() != null) ps.setDouble(24, lot.vat()); else ps.setNull(24, java.sql.Types.REAL);
|
else ps.setNull(23, java.sql.Types.BIGINT);
|
||||||
if (lot.buyerPremiumPercentage() != null) ps.setDouble(25, lot.buyerPremiumPercentage()); else ps.setNull(25, java.sql.Types.REAL);
|
if (lot.vat() != null) ps.setDouble(24, lot.vat());
|
||||||
|
else ps.setNull(24, java.sql.Types.REAL);
|
||||||
|
if (lot.buyerPremiumPercentage() != null) ps.setDouble(25, lot.buyerPremiumPercentage());
|
||||||
|
else ps.setNull(25, java.sql.Types.REAL);
|
||||||
ps.setString(26, lot.remarks());
|
ps.setString(26, lot.remarks());
|
||||||
if (lot.startingBid() != null) ps.setDouble(27, lot.startingBid()); else ps.setNull(27, java.sql.Types.REAL);
|
if (lot.startingBid() != null) ps.setDouble(27, lot.startingBid());
|
||||||
if (lot.reservePrice() != null) ps.setDouble(28, lot.reservePrice()); else ps.setNull(28, java.sql.Types.REAL);
|
else ps.setNull(27, java.sql.Types.REAL);
|
||||||
if (lot.reserveMet() != null) ps.setInt(29, lot.reserveMet() ? 1 : 0); else ps.setNull(29, java.sql.Types.INTEGER);
|
if (lot.reservePrice() != null) ps.setDouble(28, lot.reservePrice());
|
||||||
if (lot.bidIncrement() != null) ps.setDouble(30, lot.bidIncrement()); else ps.setNull(30, java.sql.Types.REAL);
|
else ps.setNull(28, java.sql.Types.REAL);
|
||||||
if (lot.viewCount() != null) ps.setInt(31, lot.viewCount()); else ps.setNull(31, java.sql.Types.INTEGER);
|
if (lot.reserveMet() != null) ps.setInt(29, lot.reserveMet() ? 1 : 0);
|
||||||
|
else ps.setNull(29, java.sql.Types.INTEGER);
|
||||||
|
if (lot.bidIncrement() != null) ps.setDouble(30, lot.bidIncrement());
|
||||||
|
else ps.setNull(30, java.sql.Types.REAL);
|
||||||
|
if (lot.viewCount() != null) ps.setInt(31, lot.viewCount());
|
||||||
|
else ps.setNull(31, java.sql.Types.INTEGER);
|
||||||
ps.setString(32, lot.firstBidTime() != null ? lot.firstBidTime().toString() : null);
|
ps.setString(32, lot.firstBidTime() != null ? lot.firstBidTime().toString() : null);
|
||||||
ps.setString(33, lot.lastBidTime() != null ? lot.lastBidTime().toString() : null);
|
ps.setString(33, lot.lastBidTime() != null ? lot.lastBidTime().toString() : null);
|
||||||
if (lot.bidVelocity() != null) ps.setDouble(34, lot.bidVelocity()); else ps.setNull(34, java.sql.Types.REAL);
|
if (lot.bidVelocity() != null) ps.setDouble(34, lot.bidVelocity());
|
||||||
|
else ps.setNull(34, java.sql.Types.REAL);
|
||||||
|
|
||||||
ps.setLong(35, lot.lotId());
|
ps.setLong(35, lot.lotId());
|
||||||
|
|
||||||
int updated = ps.executeUpdate();
|
int updated = ps.executeUpdate();
|
||||||
if (updated == 0) {
|
if (updated == 0) {
|
||||||
log.warn("Failed to update lot {} - lot not found in database", lot.lotId());
|
log.warn("Failed to update lot {} - lot not found in database", lot.lotId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inserts a complete image record (for testing/legacy compatibility).
|
* Inserts a complete image record (for testing/legacy compatibility).
|
||||||
* In production, scraper inserts with local_path, monitor updates labels via updateImageLabels.
|
* In production, scraper inserts with local_path, monitor updates labels via updateImageLabels.
|
||||||
@@ -497,7 +509,7 @@ public class DatabaseService {
|
|||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the labels field for an image after object detection
|
* Updates the labels field for an image after object detection
|
||||||
*/
|
*/
|
||||||
@@ -510,7 +522,7 @@ public class DatabaseService {
|
|||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the labels for a specific image
|
* Gets the labels for a specific image
|
||||||
*/
|
*/
|
||||||
@@ -559,7 +571,7 @@ public class DatabaseService {
|
|||||||
List<Lot> list = new ArrayList<>();
|
List<Lot> list = new ArrayList<>();
|
||||||
var sql = "SELECT lot_id, sale_id as auction_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";
|
"current_bid, currency, url, closing_time, closing_notified FROM lots";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
||||||
var rs = stmt.executeQuery(sql);
|
var rs = stmt.executeQuery(sql);
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
@@ -603,7 +615,7 @@ public class DatabaseService {
|
|||||||
try (var conn = DriverManager.getConnection(url);
|
try (var conn = DriverManager.getConnection(url);
|
||||||
var ps = conn.prepareStatement("UPDATE lots SET current_bid = ? WHERE lot_id = ?")) {
|
var ps = conn.prepareStatement("UPDATE lots SET current_bid = ? WHERE lot_id = ?")) {
|
||||||
ps.setDouble(1, lot.currentBid());
|
ps.setDouble(1, lot.currentBid());
|
||||||
ps.setLong(2, lot.lotId());
|
ps.setString(2, String.valueOf(lot.lotId()));
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,11 +627,11 @@ public class DatabaseService {
|
|||||||
try (var conn = DriverManager.getConnection(url);
|
try (var conn = DriverManager.getConnection(url);
|
||||||
var ps = conn.prepareStatement("UPDATE lots SET closing_notified = ? WHERE lot_id = ?")) {
|
var ps = conn.prepareStatement("UPDATE lots SET closing_notified = ? WHERE lot_id = ?")) {
|
||||||
ps.setInt(1, lot.closingNotified() ? 1 : 0);
|
ps.setInt(1, lot.closingNotified() ? 1 : 0);
|
||||||
ps.setLong(2, lot.lotId());
|
ps.setString(2, String.valueOf(lot.lotId()));
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves bid history for a specific lot
|
* Retrieves bid history for a specific lot
|
||||||
*/
|
*/
|
||||||
@@ -627,15 +639,15 @@ public class DatabaseService {
|
|||||||
List<BidHistory> history = new ArrayList<>();
|
List<BidHistory> history = new ArrayList<>();
|
||||||
var sql = "SELECT id, lot_id, bid_amount, bid_time, is_autobid, bidder_id, bidder_number " +
|
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";
|
"FROM bid_history WHERE lot_id = ? ORDER BY bid_time DESC LIMIT 100";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url);
|
try (var conn = DriverManager.getConnection(url);
|
||||||
var ps = conn.prepareStatement(sql)) {
|
var ps = conn.prepareStatement(sql)) {
|
||||||
ps.setString(1, lotId);
|
ps.setString(1, lotId);
|
||||||
var rs = ps.executeQuery();
|
var rs = ps.executeQuery();
|
||||||
|
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
LocalDateTime bidTime = null;
|
LocalDateTime bidTime = null;
|
||||||
var bidTimeStr = rs.getString("bid_time");
|
var bidTimeStr = rs.getString("bid_time");
|
||||||
if (bidTimeStr != null && !bidTimeStr.isBlank()) {
|
if (bidTimeStr != null && !bidTimeStr.isBlank()) {
|
||||||
try {
|
try {
|
||||||
bidTime = LocalDateTime.parse(bidTimeStr);
|
bidTime = LocalDateTime.parse(bidTimeStr);
|
||||||
@@ -643,7 +655,7 @@ public class DatabaseService {
|
|||||||
log.debug("Invalid bid_time format: {}", bidTimeStr);
|
log.debug("Invalid bid_time format: {}", bidTimeStr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
history.add(new BidHistory(
|
history.add(new BidHistory(
|
||||||
rs.getInt("id"),
|
rs.getInt("id"),
|
||||||
rs.getString("lot_id"),
|
rs.getString("lot_id"),
|
||||||
@@ -667,7 +679,7 @@ public class DatabaseService {
|
|||||||
*/
|
*/
|
||||||
synchronized List<AuctionInfo> importAuctionsFromScraper() throws SQLException {
|
synchronized List<AuctionInfo> importAuctionsFromScraper() throws SQLException {
|
||||||
List<AuctionInfo> imported = new ArrayList<>();
|
List<AuctionInfo> imported = new ArrayList<>();
|
||||||
|
|
||||||
// Derive auctions from lots table (scraper doesn't populate auctions table)
|
// Derive auctions from lots table (scraper doesn't populate auctions table)
|
||||||
var sql = """
|
var sql = """
|
||||||
SELECT
|
SELECT
|
||||||
@@ -682,7 +694,7 @@ public class DatabaseService {
|
|||||||
WHERE l.auction_id IS NOT NULL
|
WHERE l.auction_id IS NOT NULL
|
||||||
GROUP BY l.auction_id
|
GROUP BY l.auction_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
||||||
var rs = stmt.executeQuery(sql);
|
var rs = stmt.executeQuery(sql);
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
@@ -707,7 +719,7 @@ public class DatabaseService {
|
|||||||
// Table might not exist in scraper format - that's ok
|
// Table might not exist in scraper format - that's ok
|
||||||
log.info("ℹ️ Scraper lots table not found or incompatible schema: {}", e.getMessage());
|
log.info("ℹ️ Scraper lots table not found or incompatible schema: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return imported;
|
return imported;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,7 +734,7 @@ public class DatabaseService {
|
|||||||
var sql = "SELECT lot_id, auction_id, title, description, category, " +
|
var sql = "SELECT lot_id, auction_id, title, description, category, " +
|
||||||
"current_bid, closing_time, url " +
|
"current_bid, closing_time, url " +
|
||||||
"FROM lots";
|
"FROM lots";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
||||||
var rs = stmt.executeQuery(sql);
|
var rs = stmt.executeQuery(sql);
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
@@ -743,7 +755,7 @@ public class DatabaseService {
|
|||||||
// Table might not exist in scraper format - that's ok
|
// Table might not exist in scraper format - that's ok
|
||||||
log.info("ℹ️ Scraper lots table not found or incompatible schema");
|
log.info("ℹ️ Scraper lots table not found or incompatible schema");
|
||||||
}
|
}
|
||||||
|
|
||||||
return imported;
|
return imported;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,14 +774,14 @@ public class DatabaseService {
|
|||||||
AND i.local_path != ''
|
AND i.local_path != ''
|
||||||
AND (i.labels IS NULL OR i.labels = '')
|
AND (i.labels IS NULL OR i.labels = '')
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) {
|
||||||
var rs = stmt.executeQuery(sql);
|
var rs = stmt.executeQuery(sql);
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
// Extract numeric lot ID from TEXT field (e.g., "A1-34732-49" -> 3473249)
|
// Extract numeric lot ID from TEXT field (e.g., "A1-34732-49" -> 3473249)
|
||||||
String lotIdStr = rs.getString("lot_id");
|
String lotIdStr = rs.getString("lot_id");
|
||||||
long lotId = ScraperDataAdapter.extractNumericId(lotIdStr);
|
long lotId = ScraperDataAdapter.extractNumericId(lotIdStr);
|
||||||
|
|
||||||
images.add(new ImageDetectionRecord(
|
images.add(new ImageDetectionRecord(
|
||||||
rs.getInt("id"),
|
rs.getInt("id"),
|
||||||
lotId,
|
lotId,
|
||||||
@@ -779,17 +791,11 @@ public class DatabaseService {
|
|||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
log.info("ℹ️ No images needing detection found");
|
log.info("ℹ️ No images needing detection found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple record for image data from database
|
|
||||||
*/
|
|
||||||
record ImageRecord(int id, long lotId, String url, String filePath, String labels) { }
|
record ImageRecord(int id, long lotId, String url, String filePath, String labels) { }
|
||||||
|
|
||||||
/**
|
|
||||||
* Record for images that need object detection processing
|
|
||||||
*/
|
|
||||||
record ImageDetectionRecord(int id, long lotId, String filePath) { }
|
record ImageDetectionRecord(int id, long lotId, String filePath) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,55 @@
|
|||||||
package auctiora;
|
package auctiora;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
|
||||||
import jakarta.inject.Inject;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service responsible for processing images from the IMAGES table.
|
|
||||||
* Performs object detection on already-downloaded images and updates the database.
|
|
||||||
*
|
|
||||||
* NOTE: Image downloading is handled by the external scraper process.
|
|
||||||
* This service only performs object detection on images that already have local_path set.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
class ImageProcessingService {
|
public record ImageProcessingService(DatabaseService db, ObjectDetectionService detector) {
|
||||||
|
|
||||||
private final DatabaseService db;
|
boolean processImage(int id, String path, long lot) {
|
||||||
private final ObjectDetectionService detector;
|
|
||||||
|
|
||||||
ImageProcessingService(DatabaseService db, ObjectDetectionService detector) {
|
|
||||||
this.db = db;
|
|
||||||
this.detector = detector;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes a single image: runs object detection and updates labels in database.
|
|
||||||
*
|
|
||||||
* @param imageId database ID of the image record
|
|
||||||
* @param localPath local file path to the downloaded image
|
|
||||||
* @param lotId lot identifier (for logging)
|
|
||||||
* @return true if processing succeeded
|
|
||||||
*/
|
|
||||||
boolean processImage(int imageId, String localPath, long lotId) {
|
|
||||||
try {
|
try {
|
||||||
// Normalize path separators (convert Windows backslashes to forward slashes)
|
path = path.replace('\\', '/');
|
||||||
localPath = localPath.replace('\\', '/');
|
var f = new java.io.File(path);
|
||||||
|
if (!f.exists() || !f.canRead()) {
|
||||||
// Check if file exists before processing
|
log.warn("Image not accessible: {}", path);
|
||||||
var file = new java.io.File(localPath);
|
|
||||||
if (!file.exists() || !file.canRead()) {
|
|
||||||
log.warn(" Image file not accessible: {}", localPath);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (f.length() > 50L * 1024 * 1024) {
|
||||||
// Check file size (skip very large files that might cause issues)
|
log.warn("Image too large: {}", path);
|
||||||
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run object detection on the local file
|
var labels = detector.detectObjects(path);
|
||||||
var labels = detector.detectObjects(localPath);
|
db.updateImageLabels(id, labels);
|
||||||
|
|
||||||
// Update the database with detected labels
|
if (!labels.isEmpty())
|
||||||
db.updateImageLabels(imageId, labels);
|
log.info("Lot {}: {}", lot, String.join(", ", labels));
|
||||||
|
|
||||||
if (!labels.isEmpty()) {
|
|
||||||
log.info(" Lot {}: Detected {}", lotId, String.join(", ", labels));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error(" Failed to process image {}: {}", imageId, e.getMessage());
|
log.error("Process fail {}: {}", id, e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch processes all pending images in the database.
|
|
||||||
* Only processes images that have been downloaded by the scraper but haven't had object detection run yet.
|
|
||||||
*/
|
|
||||||
void processPendingImages() {
|
void processPendingImages() {
|
||||||
log.info("Processing pending images...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var pendingImages = db.getImagesNeedingDetection();
|
var images = db.getImagesNeedingDetection();
|
||||||
log.info("Found {} images needing object detection", pendingImages.size());
|
log.info("Pending {}", images.size());
|
||||||
|
|
||||||
var processed = 0;
|
int processed = 0, detected = 0;
|
||||||
var detected = 0;
|
|
||||||
|
for (var i : images) {
|
||||||
for (var image : pendingImages) {
|
if (processImage(i.id(), i.filePath(), i.lotId())) {
|
||||||
if (processImage(image.id(), image.filePath(), image.lotId())) {
|
|
||||||
processed++;
|
processed++;
|
||||||
// Re-fetch to check if labels were found
|
var lbl = db.getImageLabels(i.id());
|
||||||
var labels = db.getImageLabels(image.id());
|
if (lbl != null && !lbl.isEmpty()) detected++;
|
||||||
if (labels != null && !labels.isEmpty()) {
|
|
||||||
detected++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Processed {} images, detected objects in {}", processed, detected);
|
log.info("Processed {}, detected {}", processed, detected);
|
||||||
|
|
||||||
} catch (SQLException e) {
|
} catch (Exception e) {
|
||||||
log.error("Error processing pending images: {}", e.getMessage());
|
log.error("Batch fail: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,73 +16,66 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class LotEnrichmentScheduler {
|
public class LotEnrichmentScheduler {
|
||||||
|
|
||||||
@Inject
|
@Inject LotEnrichmentService enrichmentService;
|
||||||
LotEnrichmentService enrichmentService;
|
|
||||||
|
/**
|
||||||
/**
|
* Enriches lots closing within 1 hour - HIGH PRIORITY
|
||||||
* Enriches lots closing within 1 hour - HIGH PRIORITY
|
* Runs every 5 minutes
|
||||||
* Runs every 5 minutes
|
*/
|
||||||
*/
|
@Scheduled(cron = "0 */5 * * * ?")
|
||||||
@Scheduled(cron = "0 */5 * * * ?")
|
public void enrichCriticalLots() {
|
||||||
public void enrichCriticalLots() {
|
try {
|
||||||
try {
|
log.debug("Enriching critical lots (closing < 1 hour)");
|
||||||
log.debug("Enriching critical lots (closing < 1 hour)");
|
int enriched = enrichmentService.enrichClosingSoonLots(1);
|
||||||
int enriched = enrichmentService.enrichClosingSoonLots(1);
|
if (enriched > 0) log.info("Enriched {} critical lots", enriched);
|
||||||
if (enriched > 0) {
|
} catch (Exception e) {
|
||||||
log.info("Enriched {} critical lots", enriched);
|
log.error("Failed to enrich critical lots", e);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
}
|
||||||
log.error("Failed to enrich critical lots", e);
|
|
||||||
}
|
/**
|
||||||
}
|
* Enriches lots closing within 6 hours - MEDIUM PRIORITY
|
||||||
|
* Runs every 30 minutes
|
||||||
/**
|
*/
|
||||||
* Enriches lots closing within 6 hours - MEDIUM PRIORITY
|
@Scheduled(cron = "0 */30 * * * ?")
|
||||||
* Runs every 30 minutes
|
public void enrichUrgentLots() {
|
||||||
*/
|
try {
|
||||||
@Scheduled(cron = "0 */30 * * * ?")
|
log.debug("Enriching urgent lots (closing < 6 hours)");
|
||||||
public void enrichUrgentLots() {
|
int enriched = enrichmentService.enrichClosingSoonLots(6);
|
||||||
try {
|
if (enriched > 0) log.info("Enriched {} urgent lots", enriched);
|
||||||
log.debug("Enriching urgent lots (closing < 6 hours)");
|
} catch (Exception e) {
|
||||||
int enriched = enrichmentService.enrichClosingSoonLots(6);
|
log.error("Failed to enrich urgent lots", e);
|
||||||
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() {
|
||||||
* Enriches lots closing within 24 hours - NORMAL PRIORITY
|
try {
|
||||||
* Runs every 2 hours
|
log.debug("Enriching daily lots (closing < 24 hours)");
|
||||||
*/
|
int enriched = enrichmentService.enrichClosingSoonLots(24);
|
||||||
@Scheduled(cron = "0 0 */2 * * ?")
|
if (enriched > 0) log.info("Enriched {} daily lots", enriched);
|
||||||
public void enrichDailyLots() {
|
} catch (Exception e) {
|
||||||
try {
|
log.error("Failed to enrich daily lots", e);
|
||||||
log.debug("Enriching daily lots (closing < 24 hours)");
|
}
|
||||||
int enriched = enrichmentService.enrichClosingSoonLots(24);
|
}
|
||||||
if (enriched > 0) {
|
|
||||||
log.info("Enriched {} daily lots", enriched);
|
/**
|
||||||
}
|
* Enriches all active lots - LOW PRIORITY
|
||||||
} catch (Exception e) {
|
* Runs every 6 hours to keep all data fresh
|
||||||
log.error("Failed to enrich daily lots", e);
|
*/
|
||||||
}
|
@Scheduled(cron = "0 0 */6 * * ?")
|
||||||
}
|
public void enrichAllLots() {
|
||||||
|
try {
|
||||||
/**
|
log.info("Starting full enrichment of all lots");
|
||||||
* Enriches all active lots - LOW PRIORITY
|
int enriched = enrichmentService.enrichAllActiveLots();
|
||||||
* Runs every 6 hours to keep all data fresh
|
log.info("Full enrichment complete: {} lots updated", enriched);
|
||||||
*/
|
} catch (Exception e) {
|
||||||
@Scheduled(cron = "0 0 */6 * * ?")
|
log.error("Failed to enrich all lots", e);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,190 +16,186 @@ import java.util.stream.Collectors;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class LotEnrichmentService {
|
public class LotEnrichmentService {
|
||||||
|
|
||||||
@Inject
|
@Inject TroostwijkGraphQLClient graphQLClient;
|
||||||
TroostwijkGraphQLClient graphQLClient;
|
@Inject DatabaseService db;
|
||||||
|
/**
|
||||||
@Inject
|
* Enriches a single lot with GraphQL intelligence data
|
||||||
DatabaseService db;
|
*/
|
||||||
|
public boolean enrichLot(Lot lot) {
|
||||||
/**
|
if (lot.displayId() == null || lot.displayId().isBlank()) {
|
||||||
* Enriches a single lot with GraphQL intelligence data
|
log.debug("Cannot enrich lot {} - missing displayId", lot.lotId());
|
||||||
*/
|
return false;
|
||||||
public boolean enrichLot(Lot lot) {
|
}
|
||||||
if (lot.displayId() == null || lot.displayId().isBlank()) {
|
|
||||||
log.debug("Cannot enrich lot {} - missing displayId", lot.lotId());
|
try {
|
||||||
|
var intelligence = graphQLClient.fetchLotIntelligence(lot.displayId(), lot.lotId());
|
||||||
|
if (intelligence == null) {
|
||||||
|
log.debug("No intelligence data for lot {}", lot.displayId());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Merge intelligence with existing lot data
|
||||||
|
var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
|
||||||
|
db.upsertLotWithIntelligence(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 sequentially
|
||||||
|
* @param lots List of lots to enrich
|
||||||
|
* @return Number of successfully enriched lots
|
||||||
|
*/
|
||||||
|
public int enrichLotsBatch(List<Lot> lots) {
|
||||||
|
if (lots.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Enriching {} lots via GraphQL", lots.size());
|
||||||
|
int enrichedCount = 0;
|
||||||
|
|
||||||
|
for (var lot : lots) {
|
||||||
|
if (lot.displayId() == null || lot.displayId().isBlank()) {
|
||||||
|
log.debug("Skipping lot {} - missing displayId", lot.lotId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
var intelligence = graphQLClient.fetchLotIntelligence(lot.displayId(), lot.lotId());
|
var intelligence = graphQLClient.fetchLotIntelligence(lot.displayId(), lot.lotId());
|
||||||
if (intelligence == null) {
|
if (intelligence != null) {
|
||||||
log.debug("No intelligence data for lot {}", lot.displayId());
|
var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
|
||||||
return false;
|
db.upsertLotWithIntelligence(enrichedLot);
|
||||||
|
enrichedCount++;
|
||||||
|
} else {
|
||||||
|
log.debug("No intelligence data for lot {}", lot.displayId());
|
||||||
}
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
// Merge intelligence with existing lot data
|
log.warn("Failed to enrich lot {}: {}", lot.displayId(), e.getMessage());
|
||||||
var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
|
}
|
||||||
db.upsertLotWithIntelligence(enrichedLot);
|
|
||||||
|
// Small delay to respect rate limits (handled by RateLimitedHttpClient)
|
||||||
log.debug("Enriched lot {} with GraphQL data", lot.lotId());
|
}
|
||||||
return true;
|
|
||||||
|
log.info("Successfully enriched {}/{} lots", enrichedCount, lots.size());
|
||||||
} catch (Exception e) {
|
return enrichedCount;
|
||||||
log.warn("Failed to enrich lot {}: {}", lot.lotId(), e.getMessage());
|
}
|
||||||
return false;
|
|
||||||
}
|
/**
|
||||||
}
|
* Enriches lots closing soon (within specified hours) with higher priority
|
||||||
|
*/
|
||||||
/**
|
public int enrichClosingSoonLots(int hoursUntilClose) {
|
||||||
* Enriches multiple lots sequentially
|
try {
|
||||||
* @param lots List of lots to enrich
|
var allLots = db.getAllLots();
|
||||||
* @return Number of successfully enriched lots
|
var closingSoon = allLots.stream()
|
||||||
*/
|
.filter(lot -> lot.closingTime() != null)
|
||||||
public int enrichLotsBatch(List<Lot> lots) {
|
.filter(lot -> {
|
||||||
if (lots.isEmpty()) {
|
long minutes = lot.minutesUntilClose();
|
||||||
|
return minutes > 0 && minutes <= hoursUntilClose * 60;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (closingSoon.isEmpty()) {
|
||||||
|
log.debug("No lots closing within {} hours", hoursUntilClose);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Enriching {} lots via GraphQL", lots.size());
|
log.info("Enriching {} lots closing within {} hours", closingSoon.size(), hoursUntilClose);
|
||||||
int enrichedCount = 0;
|
return enrichLotsBatch(closingSoon);
|
||||||
|
|
||||||
for (var lot : lots) {
|
} catch (Exception e) {
|
||||||
if (lot.displayId() == null || lot.displayId().isBlank()) {
|
log.error("Failed to enrich closing soon lots: {}", e.getMessage());
|
||||||
log.debug("Skipping lot {} - missing displayId", lot.lotId());
|
return 0;
|
||||||
continue;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
try {
|
|
||||||
var intelligence = graphQLClient.fetchLotIntelligence(lot.displayId(), lot.lotId());
|
log.info("Finished enriching all lots. Total enriched: {}/{}", totalEnriched, allLots.size());
|
||||||
if (intelligence != null) {
|
return totalEnriched;
|
||||||
var enrichedLot = mergeLotWithIntelligence(lot, intelligence);
|
|
||||||
db.upsertLotWithIntelligence(enrichedLot);
|
} catch (Exception e) {
|
||||||
enrichedCount++;
|
log.error("Failed to enrich all lots: {}", e.getMessage());
|
||||||
} else {
|
return 0;
|
||||||
log.debug("No intelligence data for lot {}", lot.displayId());
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to enrich lot {}: {}", lot.displayId(), e.getMessage());
|
/**
|
||||||
}
|
* Merges existing lot data with GraphQL intelligence
|
||||||
|
*/
|
||||||
// Small delay to respect rate limits (handled by RateLimitedHttpClient)
|
private Lot mergeLotWithIntelligence(Lot lot, LotIntelligence intel) {
|
||||||
}
|
return new Lot(
|
||||||
|
lot.saleId(),
|
||||||
log.info("Successfully enriched {}/{} lots", enrichedCount, lots.size());
|
lot.lotId(),
|
||||||
return enrichedCount;
|
lot.displayId(), // Preserve displayId
|
||||||
}
|
lot.title(),
|
||||||
|
lot.description(),
|
||||||
/**
|
lot.manufacturer(),
|
||||||
* Enriches lots closing soon (within specified hours) with higher priority
|
lot.type(),
|
||||||
*/
|
lot.year(),
|
||||||
public int enrichClosingSoonLots(int hoursUntilClose) {
|
lot.category(),
|
||||||
try {
|
lot.currentBid(),
|
||||||
var allLots = db.getAllLots();
|
lot.currency(),
|
||||||
var closingSoon = allLots.stream()
|
lot.url(),
|
||||||
.filter(lot -> lot.closingTime() != null)
|
lot.closingTime(),
|
||||||
.filter(lot -> {
|
lot.closingNotified(),
|
||||||
long minutes = lot.minutesUntilClose();
|
// HIGH PRIORITY FIELDS from GraphQL
|
||||||
return minutes > 0 && minutes <= hoursUntilClose * 60;
|
intel.followersCount(),
|
||||||
})
|
intel.estimatedMin(),
|
||||||
.toList();
|
intel.estimatedMax(),
|
||||||
|
intel.nextBidStepInCents(),
|
||||||
if (closingSoon.isEmpty()) {
|
intel.condition(),
|
||||||
log.debug("No lots closing within {} hours", hoursUntilClose);
|
intel.categoryPath(),
|
||||||
return 0;
|
intel.cityLocation(),
|
||||||
}
|
intel.countryCode(),
|
||||||
|
// MEDIUM PRIORITY FIELDS
|
||||||
log.info("Enriching {} lots closing within {} hours", closingSoon.size(), hoursUntilClose);
|
intel.biddingStatus(),
|
||||||
return enrichLotsBatch(closingSoon);
|
intel.appearance(),
|
||||||
|
intel.packaging(),
|
||||||
} catch (Exception e) {
|
intel.quantity(),
|
||||||
log.error("Failed to enrich closing soon lots: {}", e.getMessage());
|
intel.vat(),
|
||||||
return 0;
|
intel.buyerPremiumPercentage(),
|
||||||
}
|
intel.remarks(),
|
||||||
}
|
// BID INTELLIGENCE FIELDS
|
||||||
|
intel.startingBid(),
|
||||||
/**
|
intel.reservePrice(),
|
||||||
* Enriches all active lots (can be slow for large datasets)
|
intel.reserveMet(),
|
||||||
*/
|
intel.bidIncrement(),
|
||||||
public int enrichAllActiveLots() {
|
intel.viewCount(),
|
||||||
try {
|
intel.firstBidTime(),
|
||||||
var allLots = db.getAllLots();
|
intel.lastBidTime(),
|
||||||
log.info("Enriching all {} active lots", allLots.size());
|
intel.bidVelocity(),
|
||||||
|
null, // condition_score (computed separately)
|
||||||
// Process in batches to avoid overwhelming the API
|
null // provenance_docs (computed separately)
|
||||||
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.displayId(), // Preserve displayId
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
package auctiora;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.opencv.core.Core;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main entry point for Troostwijk Auction Monitor.
|
|
||||||
*
|
|
||||||
* ARCHITECTURE:
|
|
||||||
* This project focuses on:
|
|
||||||
* 1. Image processing and object detection
|
|
||||||
* 2. Bid monitoring and notifications
|
|
||||||
* 3. Data enrichment
|
|
||||||
*
|
|
||||||
* Auction/Lot scraping is handled by the external ARCHITECTURE-TROOSTWIJK-SCRAPER process.
|
|
||||||
* That process populates the auctions and lots tables in the shared database.
|
|
||||||
* This process reads from those tables and enriches them with:
|
|
||||||
* - Downloaded images
|
|
||||||
* - Object detection labels
|
|
||||||
* - Bid monitoring
|
|
||||||
* - Notifications
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public class Main {
|
|
||||||
|
|
||||||
@SuppressWarnings("restricted")
|
|
||||||
private static Object loadOpenCV() {
|
|
||||||
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) throws Exception {
|
|
||||||
log.info("=== Troostwijk Auction Monitor ===\n");
|
|
||||||
|
|
||||||
// Parse command line arguments
|
|
||||||
var mode = args.length > 0 ? args[0] : "workflow";
|
|
||||||
|
|
||||||
// Configuration - Windows paths
|
|
||||||
var databaseFile = System.getenv().getOrDefault("DATABASE_FILE", "C:\\mnt\\okcomputer\\output\\cache.db");
|
|
||||||
var notificationConfig = System.getenv().getOrDefault("NOTIFICATION_CONFIG", "desktop");
|
|
||||||
|
|
||||||
// YOLO model paths (optional - monitor works without object detection)
|
|
||||||
var yoloCfg = "models/yolov4.cfg";
|
|
||||||
var yoloWeights = "models/yolov4.weights";
|
|
||||||
var yoloClasses = "models/coco.names";
|
|
||||||
|
|
||||||
// Load native OpenCV library (only if models exist)
|
|
||||||
try {
|
|
||||||
loadOpenCV();
|
|
||||||
log.info("✓ OpenCV loaded");
|
|
||||||
} catch (UnsatisfiedLinkError e) {
|
|
||||||
log.info("⚠️ OpenCV not available - image detection disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (mode.toLowerCase()) {
|
|
||||||
case "workflow":
|
|
||||||
runWorkflowMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "once":
|
|
||||||
runOnceMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "legacy":
|
|
||||||
runLegacyMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "status":
|
|
||||||
showStatus(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
showUsage();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WORKFLOW MODE: Run orchestrated scheduled workflows (default)
|
|
||||||
* This is the recommended mode for production use.
|
|
||||||
*/
|
|
||||||
private static void runWorkflowMode(String dbPath, String notifConfig,
|
|
||||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
|
||||||
throws Exception {
|
|
||||||
|
|
||||||
log.info("🚀 Starting in WORKFLOW MODE (Orchestrated Scheduling)\n");
|
|
||||||
|
|
||||||
var orchestrator = new WorkflowOrchestrator(
|
|
||||||
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show initial status
|
|
||||||
orchestrator.printStatus();
|
|
||||||
|
|
||||||
// Start all scheduled workflows
|
|
||||||
orchestrator.startScheduledWorkflows();
|
|
||||||
|
|
||||||
log.info("✓ All workflows are running");
|
|
||||||
log.info(" - Scraper import: every 30 min");
|
|
||||||
log.info(" - Image processing: every 1 hour");
|
|
||||||
log.info(" - Bid monitoring: every 15 min");
|
|
||||||
log.info(" - Closing alerts: every 5 min");
|
|
||||||
log.info("\nPress Ctrl+C to stop.\n");
|
|
||||||
|
|
||||||
// Add shutdown hook
|
|
||||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
|
||||||
log.info("\n🛑 Shutdown signal received...");
|
|
||||||
orchestrator.shutdown();
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Keep application alive
|
|
||||||
try {
|
|
||||||
Thread.sleep(Long.MAX_VALUE);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
orchestrator.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ONCE MODE: Run complete workflow once and exit
|
|
||||||
* Useful for cron jobs or scheduled tasks.
|
|
||||||
*/
|
|
||||||
private static void runOnceMode(String dbPath, String notifConfig,
|
|
||||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
|
||||||
throws Exception {
|
|
||||||
|
|
||||||
log.info("🔄 Starting in ONCE MODE (Single Execution)\n");
|
|
||||||
|
|
||||||
var orchestrator = new WorkflowOrchestrator(
|
|
||||||
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
|
|
||||||
);
|
|
||||||
|
|
||||||
orchestrator.runCompleteWorkflowOnce();
|
|
||||||
|
|
||||||
log.info("✓ Workflow execution completed. Exiting.\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LEGACY MODE: Original monitoring approach
|
|
||||||
* Kept for backward compatibility.
|
|
||||||
*/
|
|
||||||
private static void runLegacyMode(String dbPath, String notifConfig,
|
|
||||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
|
||||||
throws Exception {
|
|
||||||
|
|
||||||
log.info("⚙️ Starting in LEGACY MODE\n");
|
|
||||||
|
|
||||||
var monitor = new TroostwijkMonitor(dbPath, notifConfig,
|
|
||||||
yoloCfg, yoloWeights, yoloClasses);
|
|
||||||
|
|
||||||
log.info("\n📊 Current Database State:");
|
|
||||||
monitor.printDatabaseStats();
|
|
||||||
|
|
||||||
log.info("\n[1/2] Processing images...");
|
|
||||||
monitor.processPendingImages();
|
|
||||||
|
|
||||||
log.info("\n[2/2] Starting bid monitoring...");
|
|
||||||
monitor.scheduleMonitoring();
|
|
||||||
|
|
||||||
log.info("\n✓ Monitor is running. Press Ctrl+C to stop.\n");
|
|
||||||
log.info("NOTE: This process expects auction/lot data from the external scraper.");
|
|
||||||
log.info(" Make sure ARCHITECTURE-TROOSTWIJK-SCRAPER is running and populating the database.\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
Thread.sleep(Long.MAX_VALUE);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
log.info("Monitor interrupted, exiting.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* STATUS MODE: Show current status and exit
|
|
||||||
*/
|
|
||||||
private static void showStatus(String dbPath, String notifConfig,
|
|
||||||
String yoloCfg, String yoloWeights, String yoloClasses)
|
|
||||||
throws Exception {
|
|
||||||
|
|
||||||
log.info("📊 Checking Status...\n");
|
|
||||||
|
|
||||||
var orchestrator = new WorkflowOrchestrator(
|
|
||||||
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
|
|
||||||
);
|
|
||||||
|
|
||||||
orchestrator.printStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show usage information
|
|
||||||
*/
|
|
||||||
private static void showUsage() {
|
|
||||||
log.info("Usage: java -jar troostwijk-monitor.jar [mode]\n");
|
|
||||||
log.info("Modes:");
|
|
||||||
log.info(" workflow - Run orchestrated scheduled workflows (default)");
|
|
||||||
log.info(" once - Run complete workflow once and exit (for cron)");
|
|
||||||
log.info(" legacy - Run original monitoring approach");
|
|
||||||
log.info(" status - Show current status and exit");
|
|
||||||
log.info("\nEnvironment Variables:");
|
|
||||||
log.info(" DATABASE_FILE - Path to SQLite database");
|
|
||||||
log.info(" (default: C:\\mnt\\okcomputer\\output\\cache.db)");
|
|
||||||
log.info(" NOTIFICATION_CONFIG - 'desktop' or 'smtp:user:pass:email'");
|
|
||||||
log.info(" (default: desktop)");
|
|
||||||
log.info("\nExamples:");
|
|
||||||
log.info(" java -jar troostwijk-monitor.jar workflow");
|
|
||||||
log.info(" java -jar troostwijk-monitor.jar once");
|
|
||||||
log.info(" java -jar troostwijk-monitor.jar status");
|
|
||||||
IO.println();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alternative entry point for container environments.
|
|
||||||
* Simply keeps the container alive for manual commands.
|
|
||||||
*/
|
|
||||||
public static void main2(String[] args) {
|
|
||||||
if (args.length > 0) {
|
|
||||||
log.info("Command mode - exiting to allow shell commands");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Troostwijk Monitor container is running and healthy.");
|
|
||||||
log.info("Use 'docker exec' or 'dokku run' to execute commands.");
|
|
||||||
|
|
||||||
try {
|
|
||||||
Thread.sleep(Long.MAX_VALUE);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
log.info("Container interrupted, exiting.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +1,49 @@
|
|||||||
package auctiora;
|
package auctiora;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import jakarta.inject.Inject;
|
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
|
||||||
|
|
||||||
import javax.mail.*;
|
import javax.mail.*;
|
||||||
import javax.mail.internet.*;
|
import javax.mail.internet.*;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class NotificationService {
|
public record NotificationService(Config cfg) {
|
||||||
|
|
||||||
private final Config config;
|
// Extra convenience constructor: raw string → Config
|
||||||
|
public NotificationService(String raw) {
|
||||||
public NotificationService(String cfg) {
|
this(Config.parse(raw));
|
||||||
this.config = Config.parse(cfg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendNotification(String message, String title, int priority) {
|
public void sendNotification(String msg, String title, int prio) {
|
||||||
if (config.useDesktop()) sendDesktop(title, message, priority);
|
if (cfg.useDesktop()) sendDesktop(title, msg, prio);
|
||||||
if (config.useEmail()) sendEmail(title, message, priority);
|
if (cfg.useEmail()) sendEmail(title, msg, prio);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendDesktop(String title, String msg, int prio) {
|
private void sendDesktop(String title, String msg, int prio) {
|
||||||
try {
|
try {
|
||||||
if (!SystemTray.isSupported()) {
|
if (!SystemTray.isSupported()) {
|
||||||
log.info("Desktop notifications not supported — " + title + " / " + msg);
|
log.info("Desktop not supported: {}", title);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var tray = SystemTray.getSystemTray();
|
|
||||||
var image = Toolkit.getDefaultToolkit().createImage(new byte[0]);
|
var tray = SystemTray.getSystemTray();
|
||||||
var trayIcon = new TrayIcon(image, "NotificationService");
|
var icon = new TrayIcon(
|
||||||
trayIcon.setImageAutoSize(true);
|
Toolkit.getDefaultToolkit().createImage(new byte[0]),
|
||||||
|
"notify"
|
||||||
|
);
|
||||||
|
icon.setImageAutoSize(true);
|
||||||
|
tray.add(icon);
|
||||||
|
|
||||||
var type = prio > 0 ? TrayIcon.MessageType.WARNING : TrayIcon.MessageType.INFO;
|
var type = prio > 0 ? TrayIcon.MessageType.WARNING : TrayIcon.MessageType.INFO;
|
||||||
tray.add(trayIcon);
|
icon.displayMessage(title, msg, type);
|
||||||
trayIcon.displayMessage(title, msg, type);
|
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
tray.remove(trayIcon);
|
tray.remove(icon);
|
||||||
log.info("Desktop notification sent: " + title);
|
log.info("Desktop notification: {}", title);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("Desktop notification failed: " + e);
|
log.warn("Desktop failed: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,29 +58,32 @@ public class NotificationService {
|
|||||||
|
|
||||||
var session = Session.getInstance(props, new Authenticator() {
|
var session = Session.getInstance(props, new Authenticator() {
|
||||||
|
|
||||||
|
@Override
|
||||||
protected PasswordAuthentication getPasswordAuthentication() {
|
protected PasswordAuthentication getPasswordAuthentication() {
|
||||||
return new PasswordAuthentication(config.smtpUsername(), config.smtpPassword());
|
return new PasswordAuthentication(cfg.smtpUsername(), cfg.smtpPassword());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var m = new MimeMessage(session);
|
var m = new MimeMessage(session);
|
||||||
m.setFrom(new InternetAddress(config.smtpUsername()));
|
m.setFrom(new InternetAddress(cfg.smtpUsername()));
|
||||||
m.setRecipients(Message.RecipientType.TO, InternetAddress.parse(config.toEmail()));
|
m.setRecipients(Message.RecipientType.TO, InternetAddress.parse(cfg.toEmail()));
|
||||||
m.setSubject("[Troostwijk] " + title);
|
m.setSubject("[Troostwijk] " + title);
|
||||||
m.setText(msg);
|
m.setText(msg);
|
||||||
m.setSentDate(new Date());
|
m.setSentDate(new Date());
|
||||||
|
|
||||||
if (prio > 0) {
|
if (prio > 0) {
|
||||||
m.setHeader("X-Priority", "1");
|
m.setHeader("X-Priority", "1");
|
||||||
m.setHeader("Importance", "High");
|
m.setHeader("Importance", "High");
|
||||||
}
|
}
|
||||||
|
|
||||||
Transport.send(m);
|
Transport.send(m);
|
||||||
log.info("Email notification sent: " + title);
|
log.info("Email notification: {}", title);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.info("Email notification failed: " + e);
|
log.warn("Email failed: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private record Config(
|
public record Config(
|
||||||
boolean useDesktop,
|
boolean useDesktop,
|
||||||
boolean useEmail,
|
boolean useEmail,
|
||||||
String smtpUsername,
|
String smtpUsername,
|
||||||
@@ -87,16 +91,20 @@ public class NotificationService {
|
|||||||
String toEmail
|
String toEmail
|
||||||
) {
|
) {
|
||||||
|
|
||||||
static Config parse(String cfg) {
|
public static Config parse(String raw) {
|
||||||
if ("desktop".equalsIgnoreCase(cfg)) {
|
if ("desktop".equalsIgnoreCase(raw)) {
|
||||||
return new Config(true, false, null, null, null);
|
return new Config(true, false, null, null, null);
|
||||||
} else if (cfg.startsWith("smtp:")) {
|
|
||||||
var parts = cfg.split(":", -1); // Use -1 to include trailing empty strings
|
|
||||||
if (parts.length < 4)
|
|
||||||
throw new IllegalArgumentException("Email config must be 'smtp:username:password:toEmail'");
|
|
||||||
return new Config(true, true, parts[1], parts[2], parts[3]);
|
|
||||||
}
|
}
|
||||||
throw new IllegalArgumentException("Config must be 'desktop' or 'smtp:username:password:toEmail'");
|
|
||||||
|
if (raw != null && raw.startsWith("smtp:")) {
|
||||||
|
var p = raw.split(":", -1);
|
||||||
|
if (p.length < 4) {
|
||||||
|
throw new IllegalArgumentException("Format: smtp:username:password:toEmail");
|
||||||
|
}
|
||||||
|
return new Config(true, true, p[1], p[2], p[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalArgumentException("Use 'desktop' or 'smtp:username:password:toEmail'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ import static org.opencv.dnn.Dnn.DNN_TARGET_CPU;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class ObjectDetectionService {
|
public class ObjectDetectionService {
|
||||||
|
|
||||||
private final Net net;
|
private final Net net;
|
||||||
private final List<String> classNames;
|
private final List<String> classNames;
|
||||||
private final boolean enabled;
|
private final boolean enabled;
|
||||||
private int warnCount = 0;
|
private int warnCount = 0;
|
||||||
private static final int MAX_WARNINGS = 5;
|
private static final int MAX_WARNINGS = 5;
|
||||||
|
|
||||||
ObjectDetectionService(String cfgPath, String weightsPath, String classNamesPath) throws IOException {
|
ObjectDetectionService(String cfgPath, String weightsPath, String classNamesPath) throws IOException {
|
||||||
// Check if model files exist
|
// Check if model files exist
|
||||||
var cfgFile = Paths.get(cfgPath);
|
var cfgFile = Paths.get(cfgPath);
|
||||||
@@ -53,38 +53,38 @@ public class ObjectDetectionService {
|
|||||||
log.info(" - {}", weightsPath);
|
log.info(" - {}", weightsPath);
|
||||||
log.info(" - {}", classNamesPath);
|
log.info(" - {}", classNamesPath);
|
||||||
log.info(" Scraper will continue without image analysis.");
|
log.info(" Scraper will continue without image analysis.");
|
||||||
this.enabled = false;
|
enabled = false;
|
||||||
this.net = null;
|
net = null;
|
||||||
this.classNames = new ArrayList<>();
|
classNames = new ArrayList<>();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load network
|
// Load network
|
||||||
this.net = Dnn.readNetFromDarknet(cfgPath, weightsPath);
|
net = Dnn.readNetFromDarknet(cfgPath, weightsPath);
|
||||||
|
|
||||||
// Try to use GPU/CUDA if available, fallback to CPU
|
// Try to use GPU/CUDA if available, fallback to CPU
|
||||||
try {
|
try {
|
||||||
this.net.setPreferableBackend(Dnn.DNN_BACKEND_CUDA);
|
net.setPreferableBackend(Dnn.DNN_BACKEND_CUDA);
|
||||||
this.net.setPreferableTarget(Dnn.DNN_TARGET_CUDA);
|
net.setPreferableTarget(Dnn.DNN_TARGET_CUDA);
|
||||||
log.info("✓ Object detection enabled with YOLO (CUDA/GPU acceleration)");
|
log.info("✓ Object detection enabled with YOLO (CUDA/GPU acceleration)");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// CUDA not available, try Vulkan for AMD GPUs
|
// CUDA not available, try Vulkan for AMD GPUs
|
||||||
try {
|
try {
|
||||||
this.net.setPreferableBackend(Dnn.DNN_BACKEND_VKCOM);
|
net.setPreferableBackend(Dnn.DNN_BACKEND_VKCOM);
|
||||||
this.net.setPreferableTarget(Dnn.DNN_TARGET_VULKAN);
|
net.setPreferableTarget(Dnn.DNN_TARGET_VULKAN);
|
||||||
log.info("✓ Object detection enabled with YOLO (Vulkan/GPU acceleration)");
|
log.info("✓ Object detection enabled with YOLO (Vulkan/GPU acceleration)");
|
||||||
} catch (Exception e2) {
|
} catch (Exception e2) {
|
||||||
// GPU not available, fallback to CPU
|
// GPU not available, fallback to CPU
|
||||||
this.net.setPreferableBackend(DNN_BACKEND_OPENCV);
|
net.setPreferableBackend(DNN_BACKEND_OPENCV);
|
||||||
this.net.setPreferableTarget(DNN_TARGET_CPU);
|
net.setPreferableTarget(DNN_TARGET_CPU);
|
||||||
log.info("✓ Object detection enabled with YOLO (CPU only)");
|
log.info("✓ Object detection enabled with YOLO (CPU only)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load class names (one per line)
|
// Load class names (one per line)
|
||||||
this.classNames = Files.readAllLines(classNamesFile);
|
classNames = Files.readAllLines(classNamesFile);
|
||||||
this.enabled = true;
|
enabled = true;
|
||||||
} catch (UnsatisfiedLinkError e) {
|
} catch (UnsatisfiedLinkError e) {
|
||||||
System.err.println("⚠️ Object detection disabled: OpenCV native libraries not loaded");
|
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);
|
throw new IOException("Failed to initialize object detection: OpenCV native libraries not loaded", e);
|
||||||
@@ -121,15 +121,15 @@ public class ObjectDetectionService {
|
|||||||
var confThreshold = 0.5f;
|
var confThreshold = 0.5f;
|
||||||
for (var out : outs) {
|
for (var out : outs) {
|
||||||
// YOLO output shape: [num_detections, 85] where 85 = 4 (bbox) + 1 (objectness) + 80 (classes)
|
// YOLO output shape: [num_detections, 85] where 85 = 4 (bbox) + 1 (objectness) + 80 (classes)
|
||||||
int numDetections = out.rows();
|
int numDetections = out.rows();
|
||||||
int numElements = out.cols();
|
int numElements = out.cols();
|
||||||
int expectedLength = 5 + classNames.size();
|
int expectedLength = 5 + classNames.size();
|
||||||
|
|
||||||
if (numElements < expectedLength) {
|
if (numElements < expectedLength) {
|
||||||
// Rate-limit warnings to prevent thread blocking from excessive logging
|
// Rate-limit warnings to prevent thread blocking from excessive logging
|
||||||
if (warnCount < MAX_WARNINGS) {
|
if (warnCount < MAX_WARNINGS) {
|
||||||
log.warn("Output matrix has wrong dimensions: expected {} columns, got {}. Output shape: [{}, {}]",
|
log.warn("Output matrix has wrong dimensions: expected {} columns, got {}. Output shape: [{}, {}]",
|
||||||
expectedLength, numElements, numDetections, numElements);
|
expectedLength, numElements, numDetections, numElements);
|
||||||
warnCount++;
|
warnCount++;
|
||||||
if (warnCount == MAX_WARNINGS) {
|
if (warnCount == MAX_WARNINGS) {
|
||||||
log.warn("Suppressing further dimension warnings (reached {} warnings)", MAX_WARNINGS);
|
log.warn("Suppressing further dimension warnings (reached {} warnings)", MAX_WARNINGS);
|
||||||
@@ -137,27 +137,27 @@ public class ObjectDetectionService {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var i = 0; i < numDetections; i++) {
|
for (var i = 0; i < numDetections; i++) {
|
||||||
// Get entire row (all 85 elements)
|
// Get entire row (all 85 elements)
|
||||||
var data = new double[numElements];
|
var data = new double[numElements];
|
||||||
for (int j = 0; j < numElements; j++) {
|
for (int j = 0; j < numElements; j++) {
|
||||||
data[j] = out.get(i, j)[0];
|
data[j] = out.get(i, j)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract objectness score (index 4) and class scores (index 5+)
|
// Extract objectness score (index 4) and class scores (index 5+)
|
||||||
double objectness = data[4];
|
double objectness = data[4];
|
||||||
if (objectness < confThreshold) {
|
if (objectness < confThreshold) {
|
||||||
continue; // Skip low-confidence detections
|
continue; // Skip low-confidence detections
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract class scores
|
// Extract class scores
|
||||||
var scores = new double[classNames.size()];
|
var scores = new double[classNames.size()];
|
||||||
System.arraycopy(data, 5, scores, 0, Math.min(scores.length, data.length - 5));
|
System.arraycopy(data, 5, scores, 0, Math.min(scores.length, data.length - 5));
|
||||||
|
|
||||||
var classId = argMax(scores);
|
var classId = argMax(scores);
|
||||||
var confidence = scores[classId] * objectness; // Combine objectness with class confidence
|
var confidence = scores[classId] * objectness; // Combine objectness with class confidence
|
||||||
|
|
||||||
if (confidence > confThreshold) {
|
if (confidence > confThreshold) {
|
||||||
var label = classNames.get(classId);
|
var label = classNames.get(classId);
|
||||||
if (!labels.contains(label)) {
|
if (!labels.contains(label)) {
|
||||||
@@ -166,7 +166,7 @@ public class ObjectDetectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release resources
|
// Release resources
|
||||||
image.release();
|
image.release();
|
||||||
blob.release();
|
blob.release();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import io.quarkus.scheduler.Scheduled;
|
|||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.enterprise.event.Observes;
|
import jakarta.enterprise.event.Observes;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
@@ -22,24 +23,14 @@ public class QuarkusWorkflowScheduler {
|
|||||||
|
|
||||||
private static final Logger LOG = Logger.getLogger(QuarkusWorkflowScheduler.class);
|
private static final Logger LOG = Logger.getLogger(QuarkusWorkflowScheduler.class);
|
||||||
|
|
||||||
@Inject
|
@Inject DatabaseService db;
|
||||||
DatabaseService db;
|
@Inject NotificationService notifier;
|
||||||
|
@Inject ObjectDetectionService detector;
|
||||||
|
@Inject ImageProcessingService imageProcessor;
|
||||||
|
@Inject LotEnrichmentService enrichmentService;
|
||||||
|
|
||||||
@Inject
|
@ConfigProperty(name = "auction.database.path") String databasePath;
|
||||||
NotificationService notifier;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
ObjectDetectionService detector;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
ImageProcessingService imageProcessor;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
LotEnrichmentService enrichmentService;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "auction.database.path")
|
|
||||||
String databasePath;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggered on application startup to enrich existing lots with bid intelligence
|
* Triggered on application startup to enrich existing lots with bid intelligence
|
||||||
*/
|
*/
|
||||||
@@ -108,41 +99,41 @@ public class QuarkusWorkflowScheduler {
|
|||||||
try {
|
try {
|
||||||
LOG.info("🖼️ [WORKFLOW 2] Processing pending images...");
|
LOG.info("🖼️ [WORKFLOW 2] Processing pending images...");
|
||||||
var start = System.currentTimeMillis();
|
var start = System.currentTimeMillis();
|
||||||
|
|
||||||
// Get images that have been downloaded but need object detection
|
// Get images that have been downloaded but need object detection
|
||||||
var pendingImages = db.getImagesNeedingDetection();
|
var pendingImages = db.getImagesNeedingDetection();
|
||||||
|
|
||||||
if (pendingImages.isEmpty()) {
|
if (pendingImages.isEmpty()) {
|
||||||
LOG.info(" → No pending images to process");
|
LOG.info(" → No pending images to process");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit batch size to prevent thread blocking (max 100 images per run)
|
// Limit batch size to prevent thread blocking (max 100 images per run)
|
||||||
final int MAX_BATCH_SIZE = 100;
|
final int MAX_BATCH_SIZE = 100;
|
||||||
int totalPending = pendingImages.size();
|
int totalPending = pendingImages.size();
|
||||||
if (totalPending > MAX_BATCH_SIZE) {
|
if (totalPending > MAX_BATCH_SIZE) {
|
||||||
LOG.infof(" → Found %d pending images, processing first %d (batch limit)",
|
LOG.infof(" → Found %d pending images, processing first %d (batch limit)",
|
||||||
totalPending, MAX_BATCH_SIZE);
|
totalPending, MAX_BATCH_SIZE);
|
||||||
pendingImages = pendingImages.subList(0, MAX_BATCH_SIZE);
|
pendingImages = pendingImages.subList(0, MAX_BATCH_SIZE);
|
||||||
} else {
|
} else {
|
||||||
LOG.infof(" → Processing %d images", totalPending);
|
LOG.infof(" → Processing %d images", totalPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
var processed = 0;
|
var processed = 0;
|
||||||
var detected = 0;
|
var detected = 0;
|
||||||
var failed = 0;
|
var failed = 0;
|
||||||
|
|
||||||
for (var image : pendingImages) {
|
for (var image : pendingImages) {
|
||||||
try {
|
try {
|
||||||
// Run object detection on already-downloaded image
|
// Run object detection on already-downloaded image
|
||||||
if (imageProcessor.processImage(image.id(), image.filePath(), image.lotId())) {
|
if (imageProcessor.processImage(image.id(), image.filePath(), image.lotId())) {
|
||||||
processed++;
|
processed++;
|
||||||
|
|
||||||
// Check if objects were detected
|
// Check if objects were detected
|
||||||
var labels = db.getImageLabels(image.id());
|
var labels = db.getImageLabels(image.id());
|
||||||
if (labels != null && !labels.isEmpty()) {
|
if (labels != null && !labels.isEmpty()) {
|
||||||
detected++;
|
detected++;
|
||||||
|
|
||||||
// Send notification for interesting detections
|
// Send notification for interesting detections
|
||||||
if (labels.size() >= 3) {
|
if (labels.size() >= 3) {
|
||||||
notifier.sendNotification(
|
notifier.sendNotification(
|
||||||
@@ -151,16 +142,16 @@ public class QuarkusWorkflowScheduler {
|
|||||||
String.join(", ", labels)),
|
String.join(", ", labels)),
|
||||||
"Objects Detected",
|
"Objects Detected",
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
failed++;
|
failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting (lighter since no network I/O)
|
// Rate limiting (lighter since no network I/O)
|
||||||
Thread.sleep(100);
|
Thread.sleep(100);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
failed++;
|
failed++;
|
||||||
LOG.warnf(" ⚠️ Failed to process image: %s", e.getMessage());
|
LOG.warnf(" ⚠️ Failed to process image: %s", e.getMessage());
|
||||||
@@ -170,7 +161,7 @@ public class QuarkusWorkflowScheduler {
|
|||||||
var duration = System.currentTimeMillis() - start;
|
var duration = System.currentTimeMillis() - start;
|
||||||
LOG.infof(" ✓ Processed %d/%d images, detected objects in %d, failed %d (%.1fs)",
|
LOG.infof(" ✓ Processed %d/%d images, detected objects in %d, failed %d (%.1fs)",
|
||||||
processed, totalPending, detected, failed, duration / 1000.0);
|
processed, totalPending, detected, failed, duration / 1000.0);
|
||||||
|
|
||||||
if (totalPending > MAX_BATCH_SIZE) {
|
if (totalPending > MAX_BATCH_SIZE) {
|
||||||
LOG.infof(" → %d images remaining for next run", totalPending - MAX_BATCH_SIZE);
|
LOG.infof(" → %d images remaining for next run", totalPending - MAX_BATCH_SIZE);
|
||||||
}
|
}
|
||||||
@@ -238,7 +229,7 @@ public class QuarkusWorkflowScheduler {
|
|||||||
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
|
lot.manufacturer(), lot.type(), lot.year(), lot.category(),
|
||||||
lot.currentBid(), lot.currency(), lot.url(),
|
lot.currentBid(), lot.currency(), lot.url(),
|
||||||
lot.closingTime(), true
|
lot.closingTime(), true
|
||||||
);
|
);
|
||||||
db.updateLotNotificationFlags(updated);
|
db.updateLotNotificationFlags(updated);
|
||||||
|
|
||||||
alertsSent++;
|
alertsSent++;
|
||||||
|
|||||||
@@ -142,29 +142,15 @@ public class RateLimitedHttpClient {
|
|||||||
* Determines max requests per second for a given host.
|
* Determines max requests per second for a given host.
|
||||||
*/
|
*/
|
||||||
private int getMaxRequestsPerSecond(String host) {
|
private int getMaxRequestsPerSecond(String host) {
|
||||||
if (host.contains("troostwijk")) {
|
return host.contains("troostwijk") ? troostwijkMaxRequestsPerSecond : defaultMaxRequestsPerSecond;
|
||||||
return troostwijkMaxRequestsPerSecond;
|
|
||||||
}
|
|
||||||
return defaultMaxRequestsPerSecond;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts host from URI (e.g., "api.troostwijkauctions.com").
|
|
||||||
*/
|
|
||||||
private String extractHost(URI uri) {
|
private String extractHost(URI uri) {
|
||||||
return uri.getHost() != null ? uri.getHost() : uri.toString();
|
return uri.getHost() != null ? uri.getHost() : uri.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets statistics for all hosts.
|
|
||||||
*/
|
|
||||||
public Map<String, RequestStats> getAllStats() {
|
public Map<String, RequestStats> getAllStats() {
|
||||||
return Map.copyOf(requestStats);
|
return Map.copyOf(requestStats);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets statistics for a specific host.
|
|
||||||
*/
|
|
||||||
public RequestStats getStats(String host) {
|
public RequestStats getStats(String host) {
|
||||||
return requestStats.get(host);
|
return requestStats.get(host);
|
||||||
}
|
}
|
||||||
@@ -218,10 +204,7 @@ public class RateLimitedHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static final class RequestStats {
|
||||||
* Statistics tracker for HTTP requests per host.
|
|
||||||
*/
|
|
||||||
public static class RequestStats {
|
|
||||||
|
|
||||||
private final String host;
|
private final String host;
|
||||||
private final AtomicLong totalRequests = new AtomicLong(0);
|
private final AtomicLong totalRequests = new AtomicLong(0);
|
||||||
@@ -234,25 +217,16 @@ public class RateLimitedHttpClient {
|
|||||||
this.host = host;
|
this.host = host;
|
||||||
}
|
}
|
||||||
|
|
||||||
void incrementTotal() {
|
void incrementTotal() { totalRequests.incrementAndGet(); }
|
||||||
totalRequests.incrementAndGet();
|
|
||||||
}
|
|
||||||
|
|
||||||
void recordSuccess(long durationMs) {
|
void recordSuccess(long durationMs) {
|
||||||
successfulRequests.incrementAndGet();
|
successfulRequests.incrementAndGet();
|
||||||
totalDurationMs.addAndGet(durationMs);
|
totalDurationMs.addAndGet(durationMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
void incrementFailed() {
|
void incrementFailed() { failedRequests.incrementAndGet(); }
|
||||||
failedRequests.incrementAndGet();
|
void incrementRateLimited() { rateLimitedRequests.incrementAndGet(); }
|
||||||
}
|
public String getHost() { return host; }
|
||||||
|
|
||||||
void incrementRateLimited() {
|
|
||||||
rateLimitedRequests.incrementAndGet();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
public String getHost() { return host; }
|
|
||||||
public long getTotalRequests() { return totalRequests.get(); }
|
public long getTotalRequests() { return totalRequests.get(); }
|
||||||
public long getSuccessfulRequests() { return successfulRequests.get(); }
|
public long getSuccessfulRequests() { return successfulRequests.get(); }
|
||||||
public long getFailedRequests() { return failedRequests.get(); }
|
public long getFailedRequests() { return failedRequests.get(); }
|
||||||
|
|||||||
@@ -63,13 +63,15 @@ public class ScraperDataAdapter {
|
|||||||
lotIdStr, // Store full displayId for GraphQL queries
|
lotIdStr, // Store full displayId for GraphQL queries
|
||||||
rs.getString("title"),
|
rs.getString("title"),
|
||||||
getStringOrDefault(rs, "description", ""),
|
getStringOrDefault(rs, "description", ""),
|
||||||
"", "", 0,
|
getStringOrDefault(rs, "manufacturer", ""),
|
||||||
|
getStringOrDefault(rs, "type", ""),
|
||||||
|
getIntOrDefault(rs, "year", 0),
|
||||||
getStringOrDefault(rs, "category", ""),
|
getStringOrDefault(rs, "category", ""),
|
||||||
bid,
|
bid,
|
||||||
currency,
|
currency,
|
||||||
rs.getString("url"),
|
rs.getString("url"),
|
||||||
closing,
|
closing,
|
||||||
false,
|
getBooleanOrDefault(rs, "closing_notified", false),
|
||||||
// New intelligence fields - set to null for now
|
// 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, null, null, null, null, null,
|
||||||
@@ -166,4 +168,9 @@ public class ScraperDataAdapter {
|
|||||||
var v = rs.getInt(col);
|
var v = rs.getInt(col);
|
||||||
return rs.wasNull() ? def : v;
|
return rs.wasNull() ? def : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean getBooleanOrDefault(ResultSet rs, String col, boolean def) throws SQLException {
|
||||||
|
var v = rs.getInt(col);
|
||||||
|
return rs.wasNull() ? def : v != 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import jakarta.ws.rs.Path;
|
|||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import nu.pattern.OpenCV;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
import org.opencv.core.Core;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
@@ -19,18 +21,11 @@ public class StatusResource {
|
|||||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z")
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z")
|
||||||
.withZone(ZoneId.systemDefault());
|
.withZone(ZoneId.systemDefault());
|
||||||
|
|
||||||
@ConfigProperty(name = "application.version", defaultValue = "1.0-SNAPSHOT")
|
@ConfigProperty(name = "application.version", defaultValue = "1.0-SNAPSHOT") String appVersion;
|
||||||
String appVersion;
|
@ConfigProperty(name = "application.groupId") String groupId;
|
||||||
@ConfigProperty(name = "application.groupId")
|
@ConfigProperty(name = "application.artifactId") String artifactId;
|
||||||
String groupId;
|
@ConfigProperty(name = "application.version") String version;
|
||||||
|
|
||||||
@ConfigProperty(name = "application.artifactId")
|
|
||||||
String artifactId;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "application.version")
|
|
||||||
String version;
|
|
||||||
|
|
||||||
// Java 16+ Record for structured response
|
|
||||||
public record StatusResponse(
|
public record StatusResponse(
|
||||||
String groupId,
|
String groupId,
|
||||||
String artifactId,
|
String artifactId,
|
||||||
@@ -47,8 +42,6 @@ public class StatusResource {
|
|||||||
@Path("/status")
|
@Path("/status")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public StatusResponse getStatus() {
|
public StatusResponse getStatus() {
|
||||||
log.info("Status endpoint called");
|
|
||||||
|
|
||||||
return new StatusResponse(groupId, artifactId, version,
|
return new StatusResponse(groupId, artifactId, version,
|
||||||
"running",
|
"running",
|
||||||
FORMATTER.format(Instant.now()),
|
FORMATTER.format(Instant.now()),
|
||||||
@@ -63,8 +56,6 @@ public class StatusResource {
|
|||||||
@Path("/hello")
|
@Path("/hello")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public Map<String, String> sayHello() {
|
public Map<String, String> sayHello() {
|
||||||
log.info("hello endpoint called");
|
|
||||||
|
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"message", "Hello from Scrape-UI!",
|
"message", "Hello from Scrape-UI!",
|
||||||
"timestamp", FORMATTER.format(Instant.now()),
|
"timestamp", FORMATTER.format(Instant.now()),
|
||||||
@@ -74,9 +65,8 @@ public class StatusResource {
|
|||||||
|
|
||||||
private String getOpenCvVersion() {
|
private String getOpenCvVersion() {
|
||||||
try {
|
try {
|
||||||
// Load OpenCV if not already loaded (safe to call multiple times)
|
OpenCV.loadLocally();
|
||||||
nu.pattern.OpenCV.loadLocally();
|
return Core.VERSION;
|
||||||
return org.opencv.core.Core.VERSION;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return "4.9.0 (default)";
|
return "4.9.0 (default)";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ public class TroostwijkGraphQLClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String query = buildLotQuery();
|
var query = buildLotQuery();
|
||||||
String variables = buildVariables(displayId);
|
var variables = buildVariables(displayId);
|
||||||
|
|
||||||
// Proper GraphQL request format with query and variables
|
// Proper GraphQL request format with query and variables
|
||||||
String requestBody = String.format(
|
var requestBody = String.format(
|
||||||
"{\"query\":\"%s\",\"variables\":%s}",
|
"{\"query\":\"%s\",\"variables\":%s}",
|
||||||
escapeJson(query),
|
escapeJson(query),
|
||||||
variables
|
variables
|
||||||
);
|
);
|
||||||
|
|
||||||
var request = java.net.http.HttpRequest.newBuilder()
|
var request = java.net.http.HttpRequest.newBuilder()
|
||||||
.uri(java.net.URI.create(GRAPHQL_ENDPOINT))
|
.uri(java.net.URI.create(GRAPHQL_ENDPOINT))
|
||||||
@@ -87,15 +87,15 @@ public class TroostwijkGraphQLClient {
|
|||||||
List<LotIntelligence> results = new ArrayList<>();
|
List<LotIntelligence> results = new ArrayList<>();
|
||||||
|
|
||||||
// Split into batches of 50 to avoid query size limits
|
// Split into batches of 50 to avoid query size limits
|
||||||
int batchSize = 50;
|
var batchSize = 50;
|
||||||
for (int i = 0; i < lotIds.size(); i += batchSize) {
|
for (var i = 0; i < lotIds.size(); i += batchSize) {
|
||||||
int end = Math.min(i + batchSize, lotIds.size());
|
var end = Math.min(i + batchSize, lotIds.size());
|
||||||
List<Long> batch = lotIds.subList(i, end);
|
var batch = lotIds.subList(i, end);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String query = buildBatchLotQuery(batch);
|
var query = buildBatchLotQuery(batch);
|
||||||
String requestBody = String.format("{\"query\":\"%s\"}",
|
var requestBody = String.format("{\"query\":\"%s\"}",
|
||||||
escapeJson(query));
|
escapeJson(query));
|
||||||
|
|
||||||
var request = java.net.http.HttpRequest.newBuilder()
|
var request = java.net.http.HttpRequest.newBuilder()
|
||||||
.uri(java.net.URI.create(GRAPHQL_ENDPOINT))
|
.uri(java.net.URI.create(GRAPHQL_ENDPOINT))
|
||||||
@@ -162,9 +162,9 @@ public class TroostwijkGraphQLClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String buildBatchLotQuery(List<Long> lotIds) {
|
private String buildBatchLotQuery(List<Long> lotIds) {
|
||||||
StringBuilder query = new StringBuilder("query {");
|
var query = new StringBuilder("query {");
|
||||||
|
|
||||||
for (int i = 0; i < lotIds.size(); i++) {
|
for (var i = 0; i < lotIds.size(); i++) {
|
||||||
query.append(String.format("""
|
query.append(String.format("""
|
||||||
lot%d: lot(id: %d) {
|
lot%d: lot(id: %d) {
|
||||||
id
|
id
|
||||||
@@ -196,9 +196,9 @@ public class TroostwijkGraphQLClient {
|
|||||||
log.debug("GraphQL API returned HTML instead of JSON - likely auth required or wrong endpoint");
|
log.debug("GraphQL API returned HTML instead of JSON - likely auth required or wrong endpoint");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonNode root = objectMapper.readTree(json);
|
var root = objectMapper.readTree(json);
|
||||||
JsonNode lotNode = root.path("data").path("lotDetails");
|
var lotNode = root.path("data").path("lotDetails");
|
||||||
|
|
||||||
if (lotNode.isMissingNode()) {
|
if (lotNode.isMissingNode()) {
|
||||||
log.debug("No lotDetails in GraphQL response");
|
log.debug("No lotDetails in GraphQL response");
|
||||||
@@ -206,19 +206,19 @@ public class TroostwijkGraphQLClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract location from nested object
|
// Extract location from nested object
|
||||||
JsonNode locationNode = lotNode.path("location");
|
var locationNode = lotNode.path("location");
|
||||||
String city = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "city");
|
var city = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "city");
|
||||||
String countryCode = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "country");
|
var countryCode = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "country");
|
||||||
|
|
||||||
// Extract bids count from nested biddingStatistics
|
// Extract bids count from nested biddingStatistics
|
||||||
JsonNode statsNode = lotNode.path("biddingStatistics");
|
var statsNode = lotNode.path("biddingStatistics");
|
||||||
Integer bidsCount = statsNode.isMissingNode() ? null : getIntOrNull(statsNode, "numberOfBids");
|
var bidsCount = statsNode.isMissingNode() ? null : getIntOrNull(statsNode, "numberOfBids");
|
||||||
|
|
||||||
// Convert cents to euros for estimates
|
// Convert cents to euros for estimates
|
||||||
Long estimatedMinCents = getLongOrNull(lotNode, "estimatedValueInCentsMin");
|
var estimatedMinCents = getLongOrNull(lotNode, "estimatedValueInCentsMin");
|
||||||
Long estimatedMaxCents = getLongOrNull(lotNode, "estimatedValueInCentsMax");
|
var estimatedMaxCents = getLongOrNull(lotNode, "estimatedValueInCentsMax");
|
||||||
Double estimatedMin = estimatedMinCents != null ? estimatedMinCents.doubleValue() : null;
|
var estimatedMin = estimatedMinCents != null ? estimatedMinCents.doubleValue() : null;
|
||||||
Double estimatedMax = estimatedMaxCents != null ? estimatedMaxCents.doubleValue() : null;
|
var estimatedMax = estimatedMaxCents != null ? estimatedMaxCents.doubleValue() : null;
|
||||||
|
|
||||||
return new LotIntelligence(
|
return new LotIntelligence(
|
||||||
lotId,
|
lotId,
|
||||||
@@ -257,11 +257,11 @@ public class TroostwijkGraphQLClient {
|
|||||||
List<LotIntelligence> results = new ArrayList<>();
|
List<LotIntelligence> results = new ArrayList<>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JsonNode root = objectMapper.readTree(json);
|
var root = objectMapper.readTree(json);
|
||||||
JsonNode data = root.path("data");
|
var data = root.path("data");
|
||||||
|
|
||||||
for (int i = 0; i < lotIds.size(); i++) {
|
for (var i = 0; i < lotIds.size(); i++) {
|
||||||
JsonNode lotNode = data.path("lot" + i);
|
var lotNode = data.path("lot" + i);
|
||||||
if (!lotNode.isMissingNode()) {
|
if (!lotNode.isMissingNode()) {
|
||||||
var intelligence = parseLotIntelligenceFromNode(lotNode, lotIds.get(i));
|
var intelligence = parseLotIntelligenceFromNode(lotNode, lotIds.get(i));
|
||||||
if (intelligence != null) {
|
if (intelligence != null) {
|
||||||
@@ -313,17 +313,17 @@ public class TroostwijkGraphQLClient {
|
|||||||
|
|
||||||
private Double calculateBidVelocity(JsonNode lotNode) {
|
private Double calculateBidVelocity(JsonNode lotNode) {
|
||||||
try {
|
try {
|
||||||
Integer bidsCount = getIntOrNull(lotNode, "bidsCount");
|
var bidsCount = getIntOrNull(lotNode, "bidsCount");
|
||||||
String firstBidStr = getStringOrNull(lotNode, "firstBidTime");
|
var firstBidStr = getStringOrNull(lotNode, "firstBidTime");
|
||||||
|
|
||||||
if (bidsCount == null || firstBidStr == null || bidsCount == 0) {
|
if (bidsCount == null || firstBidStr == null || bidsCount == 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDateTime firstBid = parseDateTime(firstBidStr);
|
var firstBid = parseDateTime(firstBidStr);
|
||||||
if (firstBid == null) return null;
|
if (firstBid == null) return null;
|
||||||
|
|
||||||
long hoursElapsed = java.time.Duration.between(firstBid, LocalDateTime.now()).toHours();
|
var hoursElapsed = java.time.Duration.between(firstBid, LocalDateTime.now()).toHours();
|
||||||
if (hoursElapsed == 0) return (double) bidsCount;
|
if (hoursElapsed == 0) return (double) bidsCount;
|
||||||
|
|
||||||
return (double) bidsCount / hoursElapsed;
|
return (double) bidsCount / hoursElapsed;
|
||||||
@@ -352,27 +352,27 @@ public class TroostwijkGraphQLClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Integer getIntOrNull(JsonNode node, String field) {
|
private Integer getIntOrNull(JsonNode node, String field) {
|
||||||
JsonNode fieldNode = node.path(field);
|
var fieldNode = node.path(field);
|
||||||
return fieldNode.isNumber() ? fieldNode.asInt() : null;
|
return fieldNode.isNumber() ? fieldNode.asInt() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long getLongOrNull(JsonNode node, String field) {
|
private Long getLongOrNull(JsonNode node, String field) {
|
||||||
JsonNode fieldNode = node.path(field);
|
var fieldNode = node.path(field);
|
||||||
return fieldNode.isNumber() ? fieldNode.asLong() : null;
|
return fieldNode.isNumber() ? fieldNode.asLong() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Double getDoubleOrNull(JsonNode node, String field) {
|
private Double getDoubleOrNull(JsonNode node, String field) {
|
||||||
JsonNode fieldNode = node.path(field);
|
var fieldNode = node.path(field);
|
||||||
return fieldNode.isNumber() ? fieldNode.asDouble() : null;
|
return fieldNode.isNumber() ? fieldNode.asDouble() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getStringOrNull(JsonNode node, String field) {
|
private String getStringOrNull(JsonNode node, String field) {
|
||||||
JsonNode fieldNode = node.path(field);
|
var fieldNode = node.path(field);
|
||||||
return fieldNode.isTextual() ? fieldNode.asText() : null;
|
return fieldNode.isTextual() ? fieldNode.asText() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Boolean getBooleanOrNull(JsonNode node, String field) {
|
private Boolean getBooleanOrNull(JsonNode node, String field) {
|
||||||
JsonNode fieldNode = node.path(field);
|
var fieldNode = node.path(field);
|
||||||
return fieldNode.isBoolean() ? fieldNode.asBoolean() : null;
|
return fieldNode.isBoolean() ? fieldNode.asBoolean() : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,25 +50,25 @@ public class ValuationAnalyticsResource {
|
|||||||
public Response calculateValuation(ValuationRequest request) {
|
public Response calculateValuation(ValuationRequest request) {
|
||||||
try {
|
try {
|
||||||
LOG.infof("Valuation request for lot: %s", request.lotId);
|
LOG.infof("Valuation request for lot: %s", request.lotId);
|
||||||
long startTime = System.currentTimeMillis();
|
var startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
// Step 1: Fetch comparable sales from database
|
// Step 1: Fetch comparable sales from database
|
||||||
List<ComparableLot> comparables = fetchComparables(request);
|
var comparables = fetchComparables(request);
|
||||||
|
|
||||||
// Step 2: Calculate Fair Market Value (FMV)
|
// Step 2: Calculate Fair Market Value (FMV)
|
||||||
FairMarketValue fmv = calculateFairMarketValue(request, comparables);
|
var fmv = calculateFairMarketValue(request, comparables);
|
||||||
|
|
||||||
// Step 3: Calculate undervaluation score
|
// Step 3: Calculate undervaluation score
|
||||||
double undervaluationScore = calculateUndervaluationScore(request, fmv.value);
|
var undervaluationScore = calculateUndervaluationScore(request, fmv.value);
|
||||||
|
|
||||||
// Step 4: Predict final price
|
// Step 4: Predict final price
|
||||||
PricePrediction prediction = calculateFinalPrice(request, fmv.value);
|
var prediction = calculateFinalPrice(request, fmv.value);
|
||||||
|
|
||||||
// Step 5: Generate bidding strategy
|
// Step 5: Generate bidding strategy
|
||||||
BiddingStrategy strategy = generateBiddingStrategy(request, fmv, prediction);
|
var strategy = generateBiddingStrategy(request, fmv, prediction);
|
||||||
|
|
||||||
// Step 6: Compile response
|
// Step 6: Compile response
|
||||||
ValuationResponse response = new ValuationResponse();
|
var response = new ValuationResponse();
|
||||||
response.lotId = request.lotId;
|
response.lotId = request.lotId;
|
||||||
response.timestamp = LocalDateTime.now().toString();
|
response.timestamp = LocalDateTime.now().toString();
|
||||||
response.fairMarketValue = fmv;
|
response.fairMarketValue = fmv;
|
||||||
@@ -76,8 +76,8 @@ public class ValuationAnalyticsResource {
|
|||||||
response.pricePrediction = prediction;
|
response.pricePrediction = prediction;
|
||||||
response.biddingStrategy = strategy;
|
response.biddingStrategy = strategy;
|
||||||
response.parameters = request;
|
response.parameters = request;
|
||||||
|
|
||||||
long duration = System.currentTimeMillis() - startTime;
|
var duration = System.currentTimeMillis() - startTime;
|
||||||
LOG.infof("Valuation completed in %d ms", duration);
|
LOG.infof("Valuation completed in %d ms", duration);
|
||||||
|
|
||||||
return Response.ok(response).build();
|
return Response.ok(response).build();
|
||||||
@@ -115,24 +115,24 @@ public class ValuationAnalyticsResource {
|
|||||||
* Where weights are exponential/logistic functions of similarity
|
* Where weights are exponential/logistic functions of similarity
|
||||||
*/
|
*/
|
||||||
private FairMarketValue calculateFairMarketValue(ValuationRequest req, List<ComparableLot> comparables) {
|
private FairMarketValue calculateFairMarketValue(ValuationRequest req, List<ComparableLot> comparables) {
|
||||||
double weightedSum = 0.0;
|
var weightedSum = 0.0;
|
||||||
double weightSum = 0.0;
|
var weightSum = 0.0;
|
||||||
List<WeightedComparable> weightedComps = new ArrayList<>();
|
List<WeightedComparable> weightedComps = new ArrayList<>();
|
||||||
|
|
||||||
for (ComparableLot comp : comparables) {
|
for (var comp : comparables) {
|
||||||
// Condition weight: ω_c = exp(-λ_c · |C_target - C_i|)
|
// Condition weight: ω_c = exp(-λ_c · |C_target - C_i|)
|
||||||
double omegaC = Math.exp(-0.693 * Math.abs(req.conditionScore - comp.conditionScore));
|
var omegaC = Math.exp(-0.693 * Math.abs(req.conditionScore - comp.conditionScore));
|
||||||
|
|
||||||
// Time weight: ω_t = exp(-λ_t · |T_target - T_i|)
|
// Time weight: ω_t = exp(-λ_t · |T_target - T_i|)
|
||||||
double omegaT = Math.exp(-0.048 * Math.abs(req.manufacturingYear - comp.manufacturingYear));
|
var omegaT = Math.exp(-0.048 * Math.abs(req.manufacturingYear - comp.manufacturingYear));
|
||||||
|
|
||||||
// Provenance weight: ω_p = 1 + δ_p · (P_target - P_i)
|
// Provenance weight: ω_p = 1 + δ_p · (P_target - P_i)
|
||||||
double omegaP = 1 + 0.15 * ((req.provenanceDocs > 0 ? 1 : 0) - comp.hasProvenance);
|
var omegaP = 1 + 0.15 * ((req.provenanceDocs > 0 ? 1 : 0) - comp.hasProvenance);
|
||||||
|
|
||||||
// Historical weight: ω_h = 1 / (1 + e^(-kh · (D_i - D_median)))
|
// Historical weight: ω_h = 1 / (1 + e^(-kh · (D_i - D_median)))
|
||||||
double omegaH = 1.0 / (1 + Math.exp(-0.01 * (comp.daysAgo - 40)));
|
var omegaH = 1.0 / (1 + Math.exp(-0.01 * (comp.daysAgo - 40)));
|
||||||
|
|
||||||
double totalWeight = omegaC * omegaT * omegaP * omegaH;
|
var totalWeight = omegaC * omegaT * omegaP * omegaH;
|
||||||
|
|
||||||
weightedSum += comp.finalPrice * totalWeight;
|
weightedSum += comp.finalPrice * totalWeight;
|
||||||
weightSum += totalWeight;
|
weightSum += totalWeight;
|
||||||
@@ -140,20 +140,20 @@ public class ValuationAnalyticsResource {
|
|||||||
// Store for transparency
|
// Store for transparency
|
||||||
weightedComps.add(new WeightedComparable(comp, totalWeight, omegaC, omegaT, omegaP, omegaH));
|
weightedComps.add(new WeightedComparable(comp, totalWeight, omegaC, omegaT, omegaP, omegaH));
|
||||||
}
|
}
|
||||||
|
|
||||||
double baseFMV = weightSum > 0 ? weightedSum / weightSum : (req.estimatedMin + req.estimatedMax) / 2;
|
var baseFMV = weightSum > 0 ? weightedSum / weightSum : (req.estimatedMin + req.estimatedMax) / 2;
|
||||||
|
|
||||||
// Apply condition multiplier: M_cond = exp(α_c · √C_target - β_c)
|
// Apply condition multiplier: M_cond = exp(α_c · √C_target - β_c)
|
||||||
double conditionMultiplier = Math.exp(0.15 * Math.sqrt(req.conditionScore) - 0.40);
|
var conditionMultiplier = Math.exp(0.15 * Math.sqrt(req.conditionScore) - 0.40);
|
||||||
baseFMV *= conditionMultiplier;
|
baseFMV *= conditionMultiplier;
|
||||||
|
|
||||||
// Apply provenance premium: Δ_prov = V_base · (η_0 + η_1 · ln(1 + N_docs))
|
// Apply provenance premium: Δ_prov = V_base · (η_0 + η_1 · ln(1 + N_docs))
|
||||||
if (req.provenanceDocs > 0) {
|
if (req.provenanceDocs > 0) {
|
||||||
double provenancePremium = 0.08 + 0.035 * Math.log(1 + req.provenanceDocs);
|
var provenancePremium = 0.08 + 0.035 * Math.log(1 + req.provenanceDocs);
|
||||||
baseFMV *= (1 + provenancePremium);
|
baseFMV *= (1 + provenancePremium);
|
||||||
}
|
}
|
||||||
|
|
||||||
FairMarketValue fmv = new FairMarketValue();
|
var fmv = new FairMarketValue();
|
||||||
fmv.value = Math.round(baseFMV * 100.0) / 100.0;
|
fmv.value = Math.round(baseFMV * 100.0) / 100.0;
|
||||||
fmv.conditionMultiplier = Math.round(conditionMultiplier * 1000.0) / 1000.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.provenancePremium = req.provenanceDocs > 0 ? 0.08 + 0.035 * Math.log(1 + req.provenanceDocs) : 0.0;
|
||||||
@@ -170,12 +170,12 @@ public class ValuationAnalyticsResource {
|
|||||||
*/
|
*/
|
||||||
private double calculateUndervaluationScore(ValuationRequest req, double fmv) {
|
private double calculateUndervaluationScore(ValuationRequest req, double fmv) {
|
||||||
if (fmv <= 0) return 0.0;
|
if (fmv <= 0) return 0.0;
|
||||||
|
|
||||||
double priceGap = (fmv - req.currentBid) / fmv;
|
var priceGap = (fmv - req.currentBid) / fmv;
|
||||||
double velocityFactor = 1 + req.bidVelocity / 10.0;
|
var velocityFactor = 1 + req.bidVelocity / 10.0;
|
||||||
double watchRatio = Math.log(1 + req.watchCount / Math.max(req.bidCount, 1));
|
var watchRatio = Math.log(1 + req.watchCount / Math.max(req.bidCount, 1));
|
||||||
|
|
||||||
double uScore = priceGap * req.marketVolatility * velocityFactor * watchRatio;
|
var uScore = priceGap * req.marketVolatility * velocityFactor * watchRatio;
|
||||||
|
|
||||||
return Math.max(0.0, Math.round(uScore * 1000.0) / 1000.0);
|
return Math.max(0.0, Math.round(uScore * 1000.0) / 1000.0);
|
||||||
}
|
}
|
||||||
@@ -186,22 +186,22 @@ public class ValuationAnalyticsResource {
|
|||||||
*/
|
*/
|
||||||
private PricePrediction calculateFinalPrice(ValuationRequest req, double fmv) {
|
private PricePrediction calculateFinalPrice(ValuationRequest req, double fmv) {
|
||||||
// Bid momentum error: ε_bid = tanh(φ_1 · Λ_b - φ_2 · P_current/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));
|
var epsilonBid = Math.tanh(0.15 * req.bidVelocity - 0.10 * (req.currentBid / fmv));
|
||||||
|
|
||||||
// Time pressure error: ε_time = ψ · exp(-t_close/30)
|
// Time pressure error: ε_time = ψ · exp(-t_close/30)
|
||||||
double epsilonTime = 0.20 * Math.exp(-req.minutesUntilClose / 30.0);
|
var epsilonTime = 0.20 * Math.exp(-req.minutesUntilClose / 30.0);
|
||||||
|
|
||||||
// Competition error: ε_comp = ρ · ln(1 + W_watch/50)
|
// Competition error: ε_comp = ρ · ln(1 + W_watch/50)
|
||||||
double epsilonComp = 0.08 * Math.log(1 + req.watchCount / 50.0);
|
var epsilonComp = 0.08 * Math.log(1 + req.watchCount / 50.0);
|
||||||
|
|
||||||
double predictedPrice = fmv * (1 + epsilonBid + epsilonTime + epsilonComp);
|
var predictedPrice = fmv * (1 + epsilonBid + epsilonTime + epsilonComp);
|
||||||
|
|
||||||
// 95% confidence interval: ± 1.96 · σ_residual
|
// 95% confidence interval: ± 1.96 · σ_residual
|
||||||
double residualStdDev = fmv * 0.08; // Mock residual standard deviation
|
var residualStdDev = fmv * 0.08; // Mock residual standard deviation
|
||||||
double ciLower = predictedPrice - 1.96 * residualStdDev;
|
var ciLower = predictedPrice - 1.96 * residualStdDev;
|
||||||
double ciUpper = predictedPrice + 1.96 * residualStdDev;
|
var ciUpper = predictedPrice + 1.96 * residualStdDev;
|
||||||
|
|
||||||
PricePrediction pred = new PricePrediction();
|
var pred = new PricePrediction();
|
||||||
pred.predictedPrice = Math.round(predictedPrice * 100.0) / 100.0;
|
pred.predictedPrice = Math.round(predictedPrice * 100.0) / 100.0;
|
||||||
pred.confidenceIntervalLower = Math.round(ciLower * 100.0) / 100.0;
|
pred.confidenceIntervalLower = Math.round(ciLower * 100.0) / 100.0;
|
||||||
pred.confidenceIntervalUpper = Math.round(ciUpper * 100.0) / 100.0;
|
pred.confidenceIntervalUpper = Math.round(ciUpper * 100.0) / 100.0;
|
||||||
@@ -218,7 +218,7 @@ public class ValuationAnalyticsResource {
|
|||||||
* Generates optimal bidding strategy based on market conditions
|
* Generates optimal bidding strategy based on market conditions
|
||||||
*/
|
*/
|
||||||
private BiddingStrategy generateBiddingStrategy(ValuationRequest req, FairMarketValue fmv, PricePrediction pred) {
|
private BiddingStrategy generateBiddingStrategy(ValuationRequest req, FairMarketValue fmv, PricePrediction pred) {
|
||||||
BiddingStrategy strategy = new BiddingStrategy();
|
var strategy = new BiddingStrategy();
|
||||||
|
|
||||||
// Determine competition level
|
// Determine competition level
|
||||||
if (req.bidVelocity > 5.0) {
|
if (req.bidVelocity > 5.0) {
|
||||||
@@ -236,7 +236,7 @@ public class ValuationAnalyticsResource {
|
|||||||
strategy.recommendedTiming = "FINAL_10_MINUTES";
|
strategy.recommendedTiming = "FINAL_10_MINUTES";
|
||||||
|
|
||||||
// Adjust max bid based on undervaluation
|
// Adjust max bid based on undervaluation
|
||||||
double undervaluationScore = calculateUndervaluationScore(req, fmv.value);
|
var undervaluationScore = calculateUndervaluationScore(req, fmv.value);
|
||||||
if (undervaluationScore > 0.25) {
|
if (undervaluationScore > 0.25) {
|
||||||
// Aggressive strategy for undervalued lots
|
// Aggressive strategy for undervalued lots
|
||||||
strategy.maxBid = fmv.value * (1 + 0.05); // Conservative overbid
|
strategy.maxBid = fmv.value * (1 + 0.05); // Conservative overbid
|
||||||
@@ -270,7 +270,7 @@ public class ValuationAnalyticsResource {
|
|||||||
* Calculates confidence score based on number and quality of comparables
|
* Calculates confidence score based on number and quality of comparables
|
||||||
*/
|
*/
|
||||||
private double calculateFMVConfidence(int comparableCount, double totalWeight) {
|
private double calculateFMVConfidence(int comparableCount, double totalWeight) {
|
||||||
double confidence = 0.5; // Base confidence
|
var confidence = 0.5; // Base confidence
|
||||||
|
|
||||||
// Boost for more comparables
|
// Boost for more comparables
|
||||||
confidence += Math.min(comparableCount * 0.05, 0.3);
|
confidence += Math.min(comparableCount * 0.05, 0.3);
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ class DatabaseServiceTest {
|
|||||||
|
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
void setUp() throws SQLException {
|
void setUp() throws SQLException {
|
||||||
|
// Load SQLite JDBC driver
|
||||||
|
try {
|
||||||
|
Class.forName("org.sqlite.JDBC");
|
||||||
|
} catch (ClassNotFoundException e) {
|
||||||
|
throw new SQLException("SQLite JDBC driver not found", e);
|
||||||
|
}
|
||||||
|
|
||||||
testDbPath = "test_database_" + System.currentTimeMillis() + ".db";
|
testDbPath = "test_database_" + System.currentTimeMillis() + ".db";
|
||||||
db = new DatabaseService(testDbPath);
|
db = new DatabaseService(testDbPath);
|
||||||
db.ensureSchema();
|
db.ensureSchema();
|
||||||
|
|||||||
@@ -20,37 +20,51 @@ class ImageProcessingServiceTest {
|
|||||||
private DatabaseService mockDb;
|
private DatabaseService mockDb;
|
||||||
private ObjectDetectionService mockDetector;
|
private ObjectDetectionService mockDetector;
|
||||||
private ImageProcessingService service;
|
private ImageProcessingService service;
|
||||||
|
private java.io.File testImage;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() throws Exception {
|
||||||
mockDb = mock(DatabaseService.class);
|
mockDb = mock(DatabaseService.class);
|
||||||
mockDetector = mock(ObjectDetectionService.class);
|
mockDetector = mock(ObjectDetectionService.class);
|
||||||
service = new ImageProcessingService(mockDb, mockDetector);
|
service = new ImageProcessingService(mockDb, mockDetector);
|
||||||
|
|
||||||
|
// Create a temporary test image file
|
||||||
|
testImage = java.io.File.createTempFile("test_image_", ".jpg");
|
||||||
|
testImage.deleteOnExit();
|
||||||
|
// Write minimal JPEG header to make it a valid file
|
||||||
|
try (var out = new java.io.FileOutputStream(testImage)) {
|
||||||
|
out.write(new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF, (byte)0xE0});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should process single image and update labels")
|
@DisplayName("Should process single image and update labels")
|
||||||
void testProcessImage() throws SQLException {
|
void testProcessImage() throws SQLException {
|
||||||
// Mock object detection
|
// Normalize path (convert backslashes to forward slashes)
|
||||||
when(mockDetector.detectObjects("/path/to/image.jpg"))
|
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
||||||
|
|
||||||
|
// Mock object detection with normalized path
|
||||||
|
when(mockDetector.detectObjects(normalizedPath))
|
||||||
.thenReturn(List.of("car", "vehicle"));
|
.thenReturn(List.of("car", "vehicle"));
|
||||||
|
|
||||||
// Process image
|
// Process image
|
||||||
boolean result = service.processImage(1, "/path/to/image.jpg", 12345);
|
boolean result = service.processImage(1, testImage.getAbsolutePath(), 12345);
|
||||||
|
|
||||||
// Verify success
|
// Verify success
|
||||||
assertTrue(result);
|
assertTrue(result);
|
||||||
verify(mockDetector).detectObjects("/path/to/image.jpg");
|
verify(mockDetector).detectObjects(normalizedPath);
|
||||||
verify(mockDb).updateImageLabels(1, List.of("car", "vehicle"));
|
verify(mockDb).updateImageLabels(1, List.of("car", "vehicle"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should handle empty detection results")
|
@DisplayName("Should handle empty detection results")
|
||||||
void testProcessImageWithNoDetections() throws SQLException {
|
void testProcessImageWithNoDetections() throws SQLException {
|
||||||
when(mockDetector.detectObjects(anyString()))
|
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
||||||
|
|
||||||
|
when(mockDetector.detectObjects(normalizedPath))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
boolean result = service.processImage(2, "/path/to/image2.jpg", 12346);
|
boolean result = service.processImage(2, testImage.getAbsolutePath(), 12346);
|
||||||
|
|
||||||
assertTrue(result);
|
assertTrue(result);
|
||||||
verify(mockDb).updateImageLabels(2, List.of());
|
verify(mockDb).updateImageLabels(2, List.of());
|
||||||
@@ -59,14 +73,16 @@ class ImageProcessingServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should handle database error gracefully")
|
@DisplayName("Should handle database error gracefully")
|
||||||
void testProcessImageDatabaseError() throws SQLException {
|
void testProcessImageDatabaseError() throws SQLException {
|
||||||
when(mockDetector.detectObjects(anyString()))
|
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
||||||
|
|
||||||
|
when(mockDetector.detectObjects(normalizedPath))
|
||||||
.thenReturn(List.of("object"));
|
.thenReturn(List.of("object"));
|
||||||
|
|
||||||
doThrow(new SQLException("Database error"))
|
doThrow(new SQLException("Database error"))
|
||||||
.when(mockDb).updateImageLabels(anyInt(), anyList());
|
.when(mockDb).updateImageLabels(anyInt(), anyList());
|
||||||
|
|
||||||
// Should return false on error
|
// Should return false on error
|
||||||
boolean result = service.processImage(3, "/path/to/image3.jpg", 12347);
|
boolean result = service.processImage(3, testImage.getAbsolutePath(), 12347);
|
||||||
assertFalse(result);
|
assertFalse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +100,15 @@ class ImageProcessingServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should process pending images batch")
|
@DisplayName("Should process pending images batch")
|
||||||
void testProcessPendingImages() throws SQLException {
|
void testProcessPendingImages() throws SQLException {
|
||||||
// Mock pending images from database
|
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
||||||
|
|
||||||
|
// Mock pending images from database - use real test image path
|
||||||
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of(
|
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of(
|
||||||
new DatabaseService.ImageDetectionRecord(1, 100L, "/images/100/001.jpg"),
|
new DatabaseService.ImageDetectionRecord(1, 100L, testImage.getAbsolutePath()),
|
||||||
new DatabaseService.ImageDetectionRecord(2, 101L, "/images/101/001.jpg")
|
new DatabaseService.ImageDetectionRecord(2, 101L, testImage.getAbsolutePath())
|
||||||
));
|
));
|
||||||
|
|
||||||
when(mockDetector.detectObjects(anyString()))
|
when(mockDetector.detectObjects(normalizedPath))
|
||||||
.thenReturn(List.of("item1"))
|
.thenReturn(List.of("item1"))
|
||||||
.thenReturn(List.of("item2"));
|
.thenReturn(List.of("item2"));
|
||||||
|
|
||||||
@@ -103,7 +121,7 @@ class ImageProcessingServiceTest {
|
|||||||
|
|
||||||
// Verify all images were processed
|
// Verify all images were processed
|
||||||
verify(mockDb).getImagesNeedingDetection();
|
verify(mockDb).getImagesNeedingDetection();
|
||||||
verify(mockDetector, times(2)).detectObjects(anyString());
|
verify(mockDetector, times(2)).detectObjects(normalizedPath);
|
||||||
verify(mockDb, times(2)).updateImageLabels(anyInt(), anyList());
|
verify(mockDb, times(2)).updateImageLabels(anyInt(), anyList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,15 +139,16 @@ class ImageProcessingServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should continue processing after single image failure")
|
@DisplayName("Should continue processing after single image failure")
|
||||||
void testProcessPendingImagesWithFailure() throws SQLException {
|
void testProcessPendingImagesWithFailure() throws SQLException {
|
||||||
|
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
||||||
|
|
||||||
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of(
|
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of(
|
||||||
new DatabaseService.ImageDetectionRecord(1, 100L, "/images/100/001.jpg"),
|
new DatabaseService.ImageDetectionRecord(1, 100L, testImage.getAbsolutePath()),
|
||||||
new DatabaseService.ImageDetectionRecord(2, 101L, "/images/101/001.jpg")
|
new DatabaseService.ImageDetectionRecord(2, 101L, testImage.getAbsolutePath())
|
||||||
));
|
));
|
||||||
|
|
||||||
// First image fails, second succeeds
|
// First image fails, second succeeds
|
||||||
when(mockDetector.detectObjects("/images/100/001.jpg"))
|
when(mockDetector.detectObjects(normalizedPath))
|
||||||
.thenThrow(new RuntimeException("Detection error"));
|
.thenThrow(new RuntimeException("Detection error"))
|
||||||
when(mockDetector.detectObjects("/images/101/001.jpg"))
|
|
||||||
.thenReturn(List.of("item"));
|
.thenReturn(List.of("item"));
|
||||||
|
|
||||||
when(mockDb.getImageLabels(2))
|
when(mockDb.getImageLabels(2))
|
||||||
@@ -138,7 +157,7 @@ class ImageProcessingServiceTest {
|
|||||||
service.processPendingImages();
|
service.processPendingImages();
|
||||||
|
|
||||||
// Verify second image was still processed
|
// Verify second image was still processed
|
||||||
verify(mockDetector, times(2)).detectObjects(anyString());
|
verify(mockDetector, times(2)).detectObjects(normalizedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -154,10 +173,12 @@ class ImageProcessingServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should process images with multiple detected objects")
|
@DisplayName("Should process images with multiple detected objects")
|
||||||
void testProcessImageMultipleDetections() throws SQLException {
|
void testProcessImageMultipleDetections() throws SQLException {
|
||||||
when(mockDetector.detectObjects(anyString()))
|
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
|
||||||
|
|
||||||
|
when(mockDetector.detectObjects(normalizedPath))
|
||||||
.thenReturn(List.of("car", "truck", "vehicle", "road"));
|
.thenReturn(List.of("car", "truck", "vehicle", "road"));
|
||||||
|
|
||||||
boolean result = service.processImage(5, "/path/to/image5.jpg", 12349);
|
boolean result = service.processImage(5, testImage.getAbsolutePath(), 12349);
|
||||||
|
|
||||||
assertTrue(result);
|
assertTrue(result);
|
||||||
verify(mockDb).updateImageLabels(5, List.of("car", "truck", "vehicle", "road"));
|
verify(mockDb).updateImageLabels(5, List.of("car", "truck", "vehicle", "road"));
|
||||||
|
|||||||
Reference in New Issue
Block a user