Manual property enhancements: MLS status sync, agent clone, description formatting

- Manual properties linked to MLS now inherit status (Active/Pending/Closed) and
  days_on_market from the MLS listing dynamically
- Properties not in MLS default to Closed status
- Clone feature now auto-populates listing agent by matching MLS ID to Agent CPT
- Description formatter detects embedded headers (unpunctuated text after sentences)
  and splits them into separate paragraphs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-01-23 21:28:44 +00:00
parent c2d5b2248d
commit 57b752f54e
60 changed files with 5323 additions and 189 deletions
@@ -164,6 +164,9 @@ class MLS_Query {
/**
* Get properties matching criteria
*
* Queries both MLS and manual properties, excluding MLS entries
* that have manual overrides.
*
* @param array $args Query arguments
* @return array Property objects
*/
@@ -190,6 +193,7 @@ class MLS_Query {
'year_built_max' => null,
'listing_key' => null,
'listing_id' => null,
'agent_mls_id' => null, // Filter by list_agent_mls_id
'search' => null, // Search in address/remarks
'bounds' => null, // Map bounds: array(sw_lat, sw_lng, ne_lat, ne_lng)
'center' => null, // Map center for distance sort: array(lat, lng)
@@ -199,11 +203,15 @@ class MLS_Query {
'orderby' => 'modification_timestamp',
'order' => 'DESC',
'include_media' => false,
'include_manual' => true, // Include manual properties
'fields' => '*', // Specific fields or *
);
$args = wp_parse_args($args, $defaults);
// Get manual override IDs to exclude from MLS query
$override_ids = $args['include_manual'] ? $this->get_manual_override_listing_ids() : array();
// Build query
$table = $this->db->properties_table();
@@ -218,7 +226,8 @@ class MLS_Query {
$sql = "SELECT {$select} FROM {$table}";
// WHERE conditions
$where = array('mlg_can_view = 1');
// Exclude properties with no price or price < 100 (invalid data)
$where = array('mlg_can_view = 1', 'list_price >= 100');
$values = array();
// Add state filter (MN and IA only)
@@ -230,6 +239,12 @@ class MLS_Query {
// Exclude TBD addresses
$where[] = $this->get_tbd_exclusion_filter();
// Exclude MLS properties that have manual overrides
if (!empty($override_ids)) {
$placeholders = implode(',', array_fill(0, count($override_ids), '%s'));
$where[] = $wpdb->prepare("listing_id NOT IN ({$placeholders})", $override_ids);
}
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
@@ -270,6 +285,11 @@ class MLS_Query {
$values[] = $args['county'];
}
if ($args['agent_mls_id']) {
$where[] = 'list_agent_mls_id = %s';
$values[] = $args['agent_mls_id'];
}
if ($args['min_price']) {
$where[] = 'list_price >= %d';
$values[] = (int) $args['min_price'];
@@ -397,9 +417,59 @@ class MLS_Query {
$values[] = (int) $args['limit'];
$values[] = (int) $args['offset'];
// Execute
// Execute MLS query
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
// Query manual properties with same filters
if ($args['include_manual']) {
$manual_results = $this->get_manual_properties($args);
if (!empty($manual_results)) {
// Merge manual properties with MLS results
$results = array_merge($manual_results, $results);
// Re-sort: HomeProz first, then featured, then by orderby
$featured_ids = !empty($args['featured_ids']) ? (array) $args['featured_ids'] : array();
$order_desc = strtoupper($args['order']) !== 'ASC';
$orderby = $args['orderby'];
usort($results, function($a, $b) use ($featured_ids, $order_desc, $orderby) {
// 1. HomeProz listings first
$a_homeproz = !empty($a->is_homeproz) ? 1 : 0;
$b_homeproz = !empty($b->is_homeproz) ? 1 : 0;
if ($a_homeproz !== $b_homeproz) {
return $b_homeproz - $a_homeproz;
}
// 2. Featured listings second
$a_featured = in_array($a->listing_id, $featured_ids) || !empty($a->is_featured) ? 1 : 0;
$b_featured = in_array($b->listing_id, $featured_ids) || !empty($b->is_featured) ? 1 : 0;
if ($a_featured !== $b_featured) {
return $b_featured - $a_featured;
}
// 3. Order by specified field
$a_val = isset($a->$orderby) ? $a->$orderby : '';
$b_val = isset($b->$orderby) ? $b->$orderby : '';
if ($a_val === $b_val) {
return 0;
}
// Compare as numbers if numeric
if (is_numeric($a_val) && is_numeric($b_val)) {
$cmp = ($a_val < $b_val) ? -1 : 1;
} else {
$cmp = strcmp($a_val, $b_val);
}
return $order_desc ? -$cmp : $cmp;
});
// Apply offset and limit after merge
$results = array_slice($results, (int) $args['offset'], (int) $args['limit']);
}
}
// Include media if requested
if ($args['include_media'] && $results) {
foreach ($results as &$property) {
@@ -410,15 +480,169 @@ class MLS_Query {
return $results;
}
/**
* Get manual properties matching criteria
*
* @param array $args Query arguments (same as get_properties)
* @return array Manual property objects
*/
private function get_manual_properties($args) {
global $wpdb;
$table = $this->db->manual_properties_table();
// Check if table exists
$table_exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
DB_NAME,
$table
));
if (!$table_exists) {
return array();
}
$sql = "SELECT * FROM {$table}";
$where = array("standard_status != 'Withdrawn'");
$values = array();
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
if ($args['property_type']) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
}
if ($args['city']) {
$where[] = 'city = %s';
$values[] = $args['city'];
}
if ($args['county']) {
$where[] = 'county = %s';
$values[] = $args['county'];
}
if ($args['postal_code']) {
$where[] = 'postal_code = %s';
$values[] = $args['postal_code'];
}
if ($args['min_price']) {
$where[] = 'list_price >= %d';
$values[] = (int) $args['min_price'];
}
if ($args['max_price']) {
$where[] = 'list_price <= %d';
$values[] = (int) $args['max_price'];
}
if ($args['min_beds']) {
$where[] = 'bedrooms_total >= %d';
$values[] = (int) $args['min_beds'];
}
if ($args['max_beds']) {
$where[] = 'bedrooms_total <= %d';
$values[] = (int) $args['max_beds'];
}
if ($args['min_baths']) {
$where[] = 'bathrooms_total >= %d';
$values[] = (int) $args['min_baths'];
}
if ($args['listing_key']) {
$where[] = 'listing_key = %s';
$values[] = $args['listing_key'];
}
if ($args['listing_id']) {
$where[] = 'listing_id = %s';
$values[] = $args['listing_id'];
}
if ($args['search']) {
$search_term = '%' . $wpdb->esc_like($args['search']) . '%';
$where[] = '(full_address LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)';
$values[] = $search_term;
$values[] = $search_term;
$values[] = $search_term;
$values[] = $search_term;
}
// Map bounds filtering
if ($args['bounds'] && is_array($args['bounds']) && count($args['bounds']) === 4) {
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
$where[] = 'latitude BETWEEN %f AND %f';
$where[] = 'longitude BETWEEN %f AND %f';
$where[] = 'latitude IS NOT NULL';
$where[] = 'longitude IS NOT NULL';
$values[] = (float) $sw_lat;
$values[] = (float) $ne_lat;
$values[] = (float) $sw_lng;
$values[] = (float) $ne_lng;
}
// Radius search for manual properties
if ($args['center_lat'] && $args['center_lng']) {
$lat = (float) $args['center_lat'];
$lng = (float) $args['center_lng'];
$radius = (int) $args['radius'];
// Use simple bounding box for manual properties (no spatial index)
$lat_delta = $radius / 69.0;
$lng_delta = $radius / (69.0 * cos(deg2rad($lat)));
$where[] = 'latitude BETWEEN %f AND %f';
$where[] = 'longitude BETWEEN %f AND %f';
$values[] = $lat - $lat_delta;
$values[] = $lat + $lat_delta;
$values[] = $lng - $lng_delta;
$values[] = $lng + $lng_delta;
}
$sql .= ' WHERE ' . implode(' AND ', $where);
// Execute
if (!empty($values)) {
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
} else {
$results = $wpdb->get_results($sql);
}
// Normalize results to match MLS schema
foreach ($results as $key => $property) {
$results[$key] = $this->normalize_manual_property($property);
}
return $results;
}
/**
* Get a single property
*
* Checks manual properties first (for overrides), then MLS.
*
* @param string $identifier Listing key or listing ID
* @param bool $skip_manual_override Skip manual property override (for A/B testing)
* @return object|null Property object
*/
public function get_property($identifier) {
public function get_property($identifier, $skip_manual_override = false) {
global $wpdb;
// Check manual properties first (they can override MLS)
if (!$skip_manual_override) {
$manual = $this->get_manual_property($identifier);
if ($manual) {
return $manual;
}
}
$table = $this->db->properties_table();
// Try listing_key first
@@ -435,18 +659,193 @@ class MLS_Query {
));
}
// If found by listing_id, check if there's a manual override
if (!$skip_manual_override && $property && $property->listing_id) {
$override_ids = $this->get_manual_override_listing_ids();
if (in_array($property->listing_id, $override_ids)) {
// Manual override exists, return the manual version
return $this->get_manual_property($property->listing_id);
}
}
return $property;
}
/**
* Get a manual property by listing key or listing ID
*
* @param string $identifier Listing key (MANUAL-xxx) or listing_id
* @return object|null Property object normalized to match MLS schema
*/
private function get_manual_property($identifier) {
global $wpdb;
$table = $this->db->manual_properties_table();
// Check if table exists
$table_exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
DB_NAME,
$table
));
if (!$table_exists) {
return null;
}
// Try by listing_key first (MANUAL-xxx format)
$property = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE listing_key = %s AND standard_status != 'Withdrawn'",
$identifier
));
if (!$property) {
// Try by listing_id (MLS ID for overrides)
$property = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE listing_id = %s AND standard_status != 'Withdrawn'",
$identifier
));
}
if ($property) {
// Normalize to match MLS schema
$property = $this->normalize_manual_property($property);
}
return $property;
}
/**
* Normalize a manual property to match MLS schema
*
* @param object $property Manual property row
* @return object Normalized property
*/
private function normalize_manual_property($property) {
global $wpdb;
// Set defaults for MLS-specific fields
$property->originating_system = 'manual';
$property->mls_status = null;
$property->mlg_can_view = 1;
$property->original_list_price = $property->original_list_price ?? $property->list_price;
// For manual properties linked to MLS, use the MLS status and days_on_market
// If not in MLS (no listing_id or MLS listing not found), default to Closed
$property->days_on_market = null; // Default to null (won't display)
if (!empty($property->listing_id)) {
$mls_table = $this->db->properties_table();
$mls_data = $wpdb->get_row($wpdb->prepare(
"SELECT standard_status, days_on_market FROM {$mls_table} WHERE listing_id = %s AND mlg_can_view = 1",
$property->listing_id
));
if ($mls_data) {
$property->standard_status = $mls_data->standard_status;
$property->mls_status = $mls_data->standard_status;
$property->days_on_market = $mls_data->days_on_market;
} else {
// MLS listing no longer exists - assume Closed
$property->standard_status = 'Closed';
}
} else {
// No MLS link - pure manual property, assume Closed
$property->standard_status = 'Closed';
}
// Get agent info from linked Agent CPT
if (!empty($property->list_agent_post_id)) {
$agent_name = get_the_title($property->list_agent_post_id);
$agent_mls_id = get_field('mls_id', $property->list_agent_post_id);
$property->list_agent_name = $agent_name;
$property->list_agent_mls_id = $agent_mls_id;
$property->list_agent_key = null;
} else {
$property->list_agent_name = null;
$property->list_agent_mls_id = null;
$property->list_agent_key = null;
}
// Office info (manual listings are HomeProz)
$property->list_office_key = null;
$property->list_office_mls_id = $property->is_homeproz ? (defined('MLS_HOMEPROZ_OFFICE_ID') ? MLS_HOMEPROZ_OFFICE_ID : null) : null;
$property->list_office_name = $property->is_homeproz ? 'HomeProz Real Estate' : null;
// Date fields
$property->modification_timestamp = $property->updated_at;
$property->photos_change_timestamp = $property->updated_at;
$property->listing_contract_date = $property->list_date;
// days_on_market is set above from MLS data (or null if not in MLS)
$property->media_expires_at = null;
$property->coordinates_invalid = 0;
// Location column for spatial queries (null for manual)
$property->location = null;
// Raw data
$property->raw_data = null;
// Mark as manual for frontend display logic
$property->is_manual = 1;
return $property;
}
/**
* Get listing IDs that have manual overrides
*
* @return array Array of MLS listing_id values
*/
private function get_manual_override_listing_ids() {
global $wpdb;
// Cache result for this request
static $override_ids = null;
if ($override_ids !== null) {
return $override_ids;
}
$table = $this->db->manual_properties_table();
// Check if table exists
$table_exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
DB_NAME,
$table
));
if (!$table_exists) {
$override_ids = array();
return $override_ids;
}
$override_ids = $wpdb->get_col(
"SELECT listing_id FROM {$table}
WHERE listing_id IS NOT NULL
AND listing_id != ''
AND standard_status != 'Withdrawn'"
);
return array_filter($override_ids);
}
/**
* Get media for a property
*
* Handles both MLS and manual properties.
*
* @param string $listing_key Listing key
* @return array Media objects
*/
public function get_property_media($listing_key) {
global $wpdb;
// Check if this is a manual property
if (strpos($listing_key, 'MANUAL-') === 0) {
return MLS_Manual_Property::get_manual_property_images($listing_key);
}
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->db->media_table()}
WHERE listing_key = %s
@@ -540,15 +939,23 @@ class MLS_Query {
/**
* Get property count
*
* Counts both MLS and manual properties.
*
* @param array $args Filter arguments (same as get_properties)
* @return int Count
*/
public function get_count($args = array()) {
global $wpdb;
$include_manual = !isset($args['include_manual']) || $args['include_manual'];
// Get override IDs to exclude from MLS count
$override_ids = $include_manual ? $this->get_manual_override_listing_ids() : array();
$table = $this->db->properties_table();
$where = array('mlg_can_view = 1');
// Exclude properties with no price or price < 100 (invalid data)
$where = array('mlg_can_view = 1', 'list_price >= 100');
$values = array();
// Add state filter (MN and IA only)
@@ -560,6 +967,12 @@ class MLS_Query {
// Exclude TBD addresses
$where[] = $this->get_tbd_exclusion_filter();
// Exclude MLS properties that have manual overrides
if (!empty($override_ids)) {
$placeholders = implode(',', array_fill(0, count($override_ids), '%s'));
$where[] = $wpdb->prepare("listing_id NOT IN ({$placeholders})", $override_ids);
}
if (!empty($args['status'])) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
@@ -636,6 +1049,116 @@ class MLS_Query {
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
if (!empty($values)) {
$mls_count = (int) $wpdb->get_var($wpdb->prepare($sql, $values));
} else {
$mls_count = (int) $wpdb->get_var($sql);
}
// Add manual property count
if ($include_manual) {
$manual_count = $this->get_manual_count($args);
return $mls_count + $manual_count;
}
return $mls_count;
}
/**
* Get manual property count
*
* @param array $args Filter arguments
* @return int Count
*/
private function get_manual_count($args) {
global $wpdb;
$table = $this->db->manual_properties_table();
// Check if table exists
$table_exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
DB_NAME,
$table
));
if (!$table_exists) {
return 0;
}
$where = array("standard_status != 'Withdrawn'");
$values = array();
if (!empty($args['status'])) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
}
if (!empty($args['city'])) {
$where[] = 'city = %s';
$values[] = $args['city'];
} elseif (!empty($args['postal_code'])) {
$where[] = 'postal_code = %s';
$values[] = $args['postal_code'];
} elseif (!empty($args['center_lat']) && !empty($args['center_lng'])) {
$lat = (float) $args['center_lat'];
$lng = (float) $args['center_lng'];
$radius = !empty($args['radius']) ? (int) $args['radius'] : 30;
$lat_delta = $radius / 69.0;
$lng_delta = $radius / (69.0 * cos(deg2rad($lat)));
$where[] = 'latitude BETWEEN %f AND %f';
$where[] = 'longitude BETWEEN %f AND %f';
$values[] = $lat - $lat_delta;
$values[] = $lat + $lat_delta;
$values[] = $lng - $lng_delta;
$values[] = $lng + $lng_delta;
}
if (!empty($args['county'])) {
$where[] = 'county = %s';
$values[] = $args['county'];
}
if (!empty($args['min_price'])) {
$where[] = 'list_price >= %d';
$values[] = (int) $args['min_price'];
}
if (!empty($args['max_price'])) {
$where[] = 'list_price <= %d';
$values[] = (int) $args['max_price'];
}
if (!empty($args['min_beds'])) {
$where[] = 'bedrooms_total >= %d';
$values[] = (int) $args['min_beds'];
}
if (!empty($args['min_baths'])) {
$where[] = 'bathrooms_total >= %d';
$values[] = (int) $args['min_baths'];
}
if (!empty($args['bounds']) && is_array($args['bounds']) && count($args['bounds']) === 4) {
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
$where[] = 'latitude BETWEEN %f AND %f';
$where[] = 'longitude BETWEEN %f AND %f';
$where[] = 'latitude IS NOT NULL';
$where[] = 'longitude IS NOT NULL';
$values[] = (float) $sw_lat;
$values[] = (float) $ne_lat;
$values[] = (float) $sw_lng;
$values[] = (float) $ne_lng;
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
if (!empty($values)) {
return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
}
@@ -785,8 +1308,8 @@ class MLS_Query {
$table = $this->db->properties_table();
// Exclude properties with invalid coordinates from map bounds
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0');
// Exclude properties with invalid coordinates or invalid price from map bounds
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0', 'list_price >= 100');
$values = array();
// Add state filter (MN and IA only)