feat: initial WooDoo plugin – WooCommerce & Odoo 19 integration
- Odoo JSON-RPC client (no Composer, uses wp_remote_post) - Admin settings page under WooCommerce with connection test - Customer linking: search Odoo partners from WP user profile - My Account: Odoo Invoices tab with PDF proxy download - My Account: Book a Meeting tab (slot calculator + calendar.event) - WC order → Odoo sale.order auto-sync on processing status - Products matched by SKU; partner auto-created from billing info - Uninstall cleanup (options, user meta, order meta, DB table) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
318
includes/class-woodoo-calendar.php
Normal file
318
includes/class-woodoo-calendar.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
/**
|
||||
* My Account – Book a Meeting tab.
|
||||
* Reads booked slots from Odoo calendar.event and creates new bookings.
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
class WooDoo_Calendar {
|
||||
|
||||
const ENDPOINT = 'book-meeting';
|
||||
|
||||
public static function init(): void {
|
||||
add_filter( 'woocommerce_account_menu_items', [ __CLASS__, 'add_menu_item' ], 30 );
|
||||
add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', [ __CLASS__, 'render' ] );
|
||||
add_filter( 'woocommerce_get_query_vars', [ __CLASS__, 'add_query_var' ] );
|
||||
|
||||
add_action( 'wp_ajax_woodoo_get_slots', [ __CLASS__, 'ajax_get_slots' ] );
|
||||
add_action( 'wp_ajax_woodoo_book_slot', [ __CLASS__, 'ajax_book_slot' ] );
|
||||
add_action( 'wp_ajax_woodoo_get_meetings',[ __CLASS__, 'ajax_get_meetings' ] );
|
||||
add_action( 'wp_ajax_woodoo_cancel_meeting', [ __CLASS__, 'ajax_cancel_meeting' ] );
|
||||
}
|
||||
|
||||
public static function add_query_var( array $vars ): array {
|
||||
$vars[ self::ENDPOINT ] = self::ENDPOINT;
|
||||
return $vars;
|
||||
}
|
||||
|
||||
public static function add_menu_item( array $items ): array {
|
||||
$items[ self::ENDPOINT ] = __( 'Book a Meeting', 'woodoo' );
|
||||
return $items;
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
public static function render(): void {
|
||||
$user_id = get_current_user_id();
|
||||
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||
$user = get_userdata( $user_id );
|
||||
|
||||
$api = woodoo_api();
|
||||
if ( ! $api ) {
|
||||
echo '<p class="woodoo-notice woodoo-error">' .
|
||||
esc_html__( 'Booking is not available right now. Please contact support.', 'woodoo' ) .
|
||||
'</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Upcoming bookings for this user
|
||||
$upcoming = [];
|
||||
if ( $partner_id ) {
|
||||
$now = gmdate( 'Y-m-d H:i:s' );
|
||||
$upcoming = $api->search_read(
|
||||
'calendar.event',
|
||||
[
|
||||
[ 'partner_ids', 'in', [ $partner_id ] ],
|
||||
[ 'start', '>=', $now ],
|
||||
[ 'active', '=', true ],
|
||||
],
|
||||
[ 'id', 'name', 'start', 'stop', 'location', 'description', 'videocall_location' ],
|
||||
20,
|
||||
0,
|
||||
'start asc'
|
||||
);
|
||||
}
|
||||
|
||||
include WOODOO_DIR . 'templates/myaccount-calendar.php';
|
||||
}
|
||||
|
||||
// ── Slot Calculation ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns available booking slots for a given date (Y-m-d).
|
||||
* Filters out already-booked times from Odoo calendar.
|
||||
*
|
||||
* @return array List of ['start' => 'Y-m-d H:i', 'end' => 'Y-m-d H:i']
|
||||
*/
|
||||
public static function get_available_slots( string $date ): array {
|
||||
$available_days = (array) get_option( 'woodoo_available_days', [ 1, 2, 3, 4, 5 ] );
|
||||
$from = get_option( 'woodoo_available_from', '09:00' );
|
||||
$to = get_option( 'woodoo_available_to', '17:00' );
|
||||
$duration = (int) get_option( 'woodoo_meeting_duration', 30 );
|
||||
|
||||
$ts = strtotime( $date );
|
||||
$day_of_w = (int) gmdate( 'N', $ts ); // 1=Mon … 7=Sun
|
||||
|
||||
if ( ! in_array( $day_of_w, array_map( 'intval', $available_days ), true ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build all slots for the day
|
||||
$slots = [];
|
||||
$from_ts = strtotime( $date . ' ' . $from . ':00' );
|
||||
$to_ts = strtotime( $date . ' ' . $to . ':00' );
|
||||
$step = $duration * 60;
|
||||
|
||||
for ( $start = $from_ts; $start + $step <= $to_ts; $start += $step ) {
|
||||
$slots[] = [
|
||||
'start' => gmdate( 'Y-m-d H:i', $start ),
|
||||
'end' => gmdate( 'Y-m-d H:i', $start + $step ),
|
||||
'start_ts' => $start,
|
||||
'end_ts' => $start + $step,
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch existing events from Odoo for this day
|
||||
$api = woodoo_api();
|
||||
if ( ! $api ) return $slots;
|
||||
|
||||
$day_start = $date . ' 00:00:00';
|
||||
$day_end = $date . ' 23:59:59';
|
||||
|
||||
$cache_key = 'woodoo_booked_' . $date;
|
||||
$booked = get_transient( $cache_key );
|
||||
|
||||
if ( false === $booked ) {
|
||||
$booked = $api->search_read(
|
||||
'calendar.event',
|
||||
[
|
||||
[ 'start', '>=', $day_start ],
|
||||
[ 'start', '<=', $day_end ],
|
||||
[ 'active', '=', true ],
|
||||
],
|
||||
[ 'id', 'start', 'stop' ],
|
||||
100
|
||||
);
|
||||
set_transient( $cache_key, $booked, 120 );
|
||||
}
|
||||
|
||||
// Remove slots that overlap with existing events
|
||||
$available = array_filter( $slots, function ( $slot ) use ( $booked ) {
|
||||
foreach ( $booked as $event ) {
|
||||
$e_start = strtotime( $event['start'] );
|
||||
$e_end = strtotime( $event['stop'] );
|
||||
// Overlap: slot_start < e_end AND slot_end > e_start
|
||||
if ( $slot['start_ts'] < $e_end && $slot['end_ts'] > $e_start ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} );
|
||||
|
||||
// Strip internal timestamps before returning
|
||||
return array_values( array_map( fn($s) => [
|
||||
'start' => $s['start'],
|
||||
'end' => $s['end'],
|
||||
], $available ) );
|
||||
}
|
||||
|
||||
// ── AJAX: Get Slots ───────────────────────────────────────────────────
|
||||
|
||||
public static function ajax_get_slots(): void {
|
||||
check_ajax_referer( 'woodoo_calendar', 'nonce' );
|
||||
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
|
||||
|
||||
$date = sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) );
|
||||
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
|
||||
wp_send_json_error( 'Invalid date format' );
|
||||
}
|
||||
|
||||
// Don't allow past dates
|
||||
if ( strtotime( $date ) < strtotime( 'today' ) ) {
|
||||
wp_send_json_success( [] );
|
||||
}
|
||||
|
||||
wp_send_json_success( self::get_available_slots( $date ) );
|
||||
}
|
||||
|
||||
// ── AJAX: Book Slot ───────────────────────────────────────────────────
|
||||
|
||||
public static function ajax_book_slot(): void {
|
||||
check_ajax_referer( 'woodoo_calendar', 'nonce' );
|
||||
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
|
||||
|
||||
$start = sanitize_text_field( wp_unslash( $_POST['start'] ?? '' ) );
|
||||
$end = sanitize_text_field( wp_unslash( $_POST['end'] ?? '' ) );
|
||||
$notes = sanitize_textarea_field( wp_unslash( $_POST['notes'] ?? '' ) );
|
||||
|
||||
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $start )
|
||||
|| ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $end ) ) {
|
||||
wp_send_json_error( 'Invalid date/time format' );
|
||||
}
|
||||
|
||||
if ( strtotime( $start ) < time() ) {
|
||||
wp_send_json_error( __( 'Cannot book a slot in the past.', 'woodoo' ) );
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$user = get_userdata( $user_id );
|
||||
$api = woodoo_api();
|
||||
|
||||
if ( ! $api ) wp_send_json_error( 'API not configured' );
|
||||
|
||||
// Find or create Odoo partner for the customer
|
||||
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||
if ( ! $partner_id ) {
|
||||
$partner_id = $api->find_or_create_partner(
|
||||
$user->user_email,
|
||||
$user->display_name
|
||||
);
|
||||
if ( $partner_id ) {
|
||||
update_user_meta( $user_id, 'woodoo_odoo_partner_id', $partner_id );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $partner_id ) {
|
||||
wp_send_json_error( __( 'Could not find your Odoo record.', 'woodoo' ) );
|
||||
}
|
||||
|
||||
// Get the Odoo user (organizer) – the API user
|
||||
$me_uid = $api->authenticate();
|
||||
// Find res.users for this uid to get partner_id of organizer
|
||||
$organizer_users = $api->search_read(
|
||||
'res.users',
|
||||
[ [ 'id', '=', $me_uid ] ],
|
||||
[ 'partner_id' ],
|
||||
1
|
||||
);
|
||||
$organizer_partner_id = $organizer_users[0]['partner_id'][0] ?? null;
|
||||
|
||||
$title = get_option( 'woodoo_meeting_title_prefix', 'Meeting via WooDoo' )
|
||||
. ' – ' . $user->display_name;
|
||||
|
||||
$partner_ids = array_filter( [ $partner_id, $organizer_partner_id ] );
|
||||
|
||||
$event_vals = [
|
||||
'name' => $title,
|
||||
'start' => $start . ':00',
|
||||
'stop' => $end . ':00',
|
||||
'description' => $notes ?: false,
|
||||
'partner_ids' => array_map( fn($id) => [ 4, $id, 0 ], $partner_ids ),
|
||||
'privacy' => 'confidential',
|
||||
];
|
||||
|
||||
$event_id = $api->create( 'calendar.event', $event_vals );
|
||||
|
||||
if ( ! $event_id ) {
|
||||
wp_send_json_error( __( 'Could not create meeting in Odoo. Please try again.', 'woodoo' ) );
|
||||
}
|
||||
|
||||
// Invalidate booked slots cache for this date
|
||||
$date = substr( $start, 0, 10 );
|
||||
delete_transient( 'woodoo_booked_' . $date );
|
||||
|
||||
wp_send_json_success( [
|
||||
'event_id' => $event_id,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'message' => __( 'Meeting booked! You will receive a confirmation from Odoo.', 'woodoo' ),
|
||||
] );
|
||||
}
|
||||
|
||||
// ── AJAX: Get User's Meetings ─────────────────────────────────────────
|
||||
|
||||
public static function ajax_get_meetings(): void {
|
||||
check_ajax_referer( 'woodoo_calendar', 'nonce' );
|
||||
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||
if ( ! $partner_id ) wp_send_json_success( [] );
|
||||
|
||||
$api = woodoo_api();
|
||||
if ( ! $api ) wp_send_json_error( 'API not configured' );
|
||||
|
||||
$now = gmdate( 'Y-m-d H:i:s' );
|
||||
$meetings = $api->search_read(
|
||||
'calendar.event',
|
||||
[
|
||||
[ 'partner_ids', 'in', [ $partner_id ] ],
|
||||
[ 'start', '>=', $now ],
|
||||
[ 'active', '=', true ],
|
||||
],
|
||||
[ 'id', 'name', 'start', 'stop', 'location', 'videocall_location' ],
|
||||
20,
|
||||
0,
|
||||
'start asc'
|
||||
);
|
||||
|
||||
wp_send_json_success( $meetings );
|
||||
}
|
||||
|
||||
// ── AJAX: Cancel Meeting ──────────────────────────────────────────────
|
||||
|
||||
public static function ajax_cancel_meeting(): void {
|
||||
check_ajax_referer( 'woodoo_calendar', 'nonce' );
|
||||
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
|
||||
|
||||
$event_id = absint( $_POST['event_id'] ?? 0 );
|
||||
$user_id = get_current_user_id();
|
||||
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
|
||||
|
||||
if ( ! $event_id || ! $partner_id ) wp_send_json_error( 'Invalid request' );
|
||||
|
||||
$api = woodoo_api();
|
||||
if ( ! $api ) wp_send_json_error( 'API not configured' );
|
||||
|
||||
// Verify the event belongs to this user
|
||||
$events = $api->search(
|
||||
'calendar.event',
|
||||
[ [ 'id', '=', $event_id ], [ 'partner_ids', 'in', [ $partner_id ] ] ],
|
||||
1
|
||||
);
|
||||
|
||||
if ( empty( $events ) ) {
|
||||
wp_send_json_error( __( 'Meeting not found.', 'woodoo' ) );
|
||||
}
|
||||
|
||||
// Unlink the customer partner from the event (don't delete the event)
|
||||
$api->write(
|
||||
'calendar.event',
|
||||
[ $event_id ],
|
||||
[ 'partner_ids' => [ [ 3, $partner_id, 0 ] ] ]
|
||||
);
|
||||
|
||||
wp_send_json_success( [ 'message' => __( 'Meeting cancelled.', 'woodoo' ) ] );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user