__('Branch Stock Scanner', 'ai-eshop-optimizer'),
'description' => __('Multi-branch / multi-warehouse stock visibility from inside the WooCommerce admin. Scan a SKU or barcode and see availability per location, transfer history, and per-branch sell-through — so staff in store #2 stop telling customers a SKU is out of stock when #4 has six on the shelf.', 'ai-eshop-optimizer'),
'bullets' => array(
__('Per-branch stock counters with one-click rebalance.', 'ai-eshop-optimizer'),
__('Barcode scanner UI optimised for handheld + tablet on the warehouse floor.', 'ai-eshop-optimizer'),
__('Integrates with the ERP Bridge for live-from-truck updates.', 'ai-eshop-optimizer'),
),
);
}
const SCAN_NONCE_ACTION = 'aieo_dmm_scan';
public static function worksheet_slugs() {
if (!class_exists('AIEO_DMM_Branches')) {
$f = AIEO_DIR . 'includes/dmm/services/class-aieo-dmm-branches.php';
if (is_readable($f)) { require_once $f; }
}
if (!class_exists('AIEO_DMM_Branches')) { return array(); }
$out = array();
foreach (AIEO_DMM_Branches::cached() as $b) {
if ('' !== (string) $b['slug']) { $out[] = (string) $b['slug']; }
}
return $out;
}
public static function user_can_scan() {
return current_user_can('aieo_use_stock_scanner')
|| current_user_can('manage_woocommerce')
|| current_user_can('edit_products');
}
public static function worksheet_cap($slug) {
return 'aieo_preorder_ws_' . $slug;
}
public static function seed_worksheet_caps_once() {
if (!class_exists('AIEO_DMM_Branches')) {
return;
}
$admin = get_role('administrator');
$seeded = get_option('aieo_preorder_ws_seeded_slugs', null);
if (!is_array($seeded)) {
$seeded = array();
if ((int) get_option('aieo_preorder_ws_seed', 0) >= 1) {
foreach (AIEO_DMM_Branches::cached() as $b) {
$ws = (string) $b['slug'];
if ('' === $ws) { continue; }
if (null !== get_role('rooster_branch_' . $ws) || ($admin && $admin->has_cap(self::worksheet_cap($ws)))) {
$seeded[] = $ws;
}
}
}
}
$dirty = false;
foreach (AIEO_DMM_Branches::cached() as $b) {
$ws = (string) $b['slug'];
if ($ws === '' || in_array($ws, $seeded, true)) { continue; }
if (null === get_role('rooster_branch_' . $ws)) {
add_role(
'rooster_branch_' . $ws,
sprintf(__('Branch staff: %s', 'ai-eshop-optimizer'), (string) $b['label']),
array(
'read' => true,
'aieo_access' => true,
'aieo_use_stock_scanner' => true,
self::worksheet_cap($ws) => true,
)
);
}
if ($admin) {
$admin->add_cap(self::worksheet_cap($ws));
}
$seeded[] = $ws;
$dirty = true;
}
if ($dirty || null === get_option('aieo_preorder_ws_seeded_slugs', null)) {
update_option('aieo_preorder_ws_seeded_slugs', array_values(array_unique($seeded)), false);
}
}
public function register_hooks() {
add_action('wp_ajax_aieo_dmm_branch_save', array($this, 'ajax_save_branch'));
add_action('wp_ajax_aieo_dmm_branch_delete', array($this, 'ajax_delete_branch'));
add_action('wp_ajax_aieo_dmm_branch_erp_settings', array($this, 'ajax_save_erp_settings'));
add_action('wp_ajax_aieo_dmm_preorder_set_status', array($this, 'ajax_preorder_set_status'));
add_action('wp_ajax_aieo_dmm_scanner_staff_lookup', array($this, 'ajax_staff_lookup'));
add_action('wp_ajax_aieo_dmm_scanner_staff_update', array($this, 'ajax_staff_update'));
add_action('wp_ajax_aieo_dmm_scanner_log_missing', array($this, 'ajax_log_missing'));
add_action('wp_ajax_aieo_dmm_scanner_customer_lookup', array($this, 'ajax_customer_lookup'));
add_action('wp_ajax_nopriv_aieo_dmm_scanner_customer_lookup', array($this, 'ajax_customer_lookup'));
add_shortcode('aieo_dmm_branch_scanner', array($this, 'shortcode_staff_scanner'));
add_shortcode('aieo_dmm_customer_scanner', array($this, 'shortcode_customer_scanner'));
add_filter('aieo_report_save_cell', array($this, 'report_save_cell'), 10, 3);
add_filter('aieo_report_bulk', array($this, 'report_bulk'), 10, 3);
if (is_admin()) {
add_action('admin_init', array(__CLASS__, 'ensure_role_registered'));
}
add_filter('woocommerce_prevent_admin_access', array(__CLASS__, 'keep_staff_admin'));
add_filter('woocommerce_disable_admin_bar', array(__CLASS__, 'keep_staff_admin'));
}
public static function keep_staff_admin($prevent) {
if (!$prevent || !is_user_logged_in()) { return $prevent; }
if (current_user_can('aieo_use_stock_scanner')) { return false; }
if (class_exists('AIEO_DMM_Branches')) {
foreach ((array) AIEO_DMM_Branches::cached() as $b) {
$ws = isset($b['slug']) ? (string) $b['slug'] : '';
if ($ws !== '' && current_user_can(self::worksheet_cap($ws))) { return false; }
}
}
return $prevent;
}
public static function ensure_role_registered() {
if (class_exists('AIEO_DMM_Branch_Provisioner')) {
AIEO_DMM_Branch_Provisioner::ensure_scanner_role();
}
self::seed_worksheet_caps_once();
}
public function get_tab_arg() { return 'bs_section'; }
public function get_tabs() {
$tabs = array('scanning' => array('label' => __('Scanning', 'ai-eshop-optimizer'), 'icon' => 'activity'));
if (class_exists('AIEO_DMM_Branches')) {
foreach (AIEO_DMM_Branches::cached() as $b) {
if ('' === (string) $b['slug']) { continue; }
$tabs['worksheet_' . $b['slug']] = array('label' => sprintf(__('Worksheet: %s', 'ai-eshop-optimizer'), (string) $b['label']), 'icon' => 'clipboard-list');
}
}
return $tabs;
}
public function render_subtab() {
$tabs = $this->get_tabs();
foreach (self::worksheet_slugs() as $aieo_ws) {
if (!current_user_can(self::worksheet_cap($aieo_ws))) {
unset($tabs['worksheet_' . $aieo_ws]);
}
}
$section = isset($_GET['bs_section']) ? sanitize_key((string) wp_unslash($_GET['bs_section'])) : 'scanning';
if (!isset($tabs[$section])) { $section = 'scanning'; }
AIEO_DMM_UI::tab_strip($this->get_id(), $tabs, $section, array(
'arg' => $this->get_tab_arg(), 'wrapper' => 'div', 'margin' => '14px',
));
if (0 === strpos($section, 'worksheet_')) {
$this->render_worksheet(substr($section, strlen('worksheet_')), $tabs[$section]);
return;
}
$this->render_template('branch-scanner.php', array(
'branches' => $this->fetch_branches(),
'recent_log' => $this->fetch_recent_log(25),
));
}
private function render_worksheet($slug, $tab_label) {
if (!in_array($slug, self::worksheet_slugs(), true)) {
return;
}
if (!current_user_can(self::worksheet_cap($slug))) {
echo '
'
. esc_html__('You do not have access to this branch\'s worksheet.', 'ai-eshop-optimizer')
. '
';
return;
}
$branch = $this->get_branch_by_slug($slug);
if (!$branch) {
echo ''
. esc_html__('This branch is not enabled in the branch registry, so its worksheet is read-only history. Enable it under Scanning to work the sheet.', 'ai-eshop-optimizer')
. '
';
}
if (class_exists('AIEO_Report_Render')) {
AIEO_Report_Render::card('preorder_worksheet_' . $slug, array(
'anchor' => 'branch-scanner-worksheet-' . $slug,
'heading' => 'h2',
'icon' => 'clipboard-list',
'title' => $tab_label,
'description' => __( 'The weekly restock pass: the whole preorder-eligible catalogue with this branch\'s own 30/60-day sales. Type the shelf count and the quantity to request, then Submit requests — lines without a quantity are skipped, and a line with an open request updates that request instead of creating a duplicate. The scanner stays the fast path for ad-hoc requests.', 'ai-eshop-optimizer' ),
'footnote' => __('Quantities autosave as you type them and survive the nightly catalogue rebuild. Submitting clears the sheet and files the requests under Supplier Orders for approval.', 'ai-eshop-optimizer'),
));
}
}
public function fetch_branches() {
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_branches';
if (!aieo_dmm_table_exists($table)) {
return array();
}
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, label ASC", ARRAY_A);
return is_array($rows) ? $rows : array();
}
public function fetch_enabled_branches() {
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_branches';
if (!aieo_dmm_table_exists($table)) {
return array();
}
$rows = $wpdb->get_results("SELECT * FROM {$table} WHERE enabled = 1 ORDER BY sort_order ASC, label ASC", ARRAY_A);
return is_array($rows) ? $rows : array();
}
private function fetch_recent_log($limit = 25) {
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_branch_scan_log';
if (!aieo_dmm_table_exists($table)) {
return array();
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d",
(int) $limit
), ARRAY_A);
return is_array($rows) ? $rows : array();
}
public function ajax_save_branch() {
AIEO_DMM::verify_ajax_request();
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_branches';
if (!aieo_dmm_table_exists($table)) {
wp_send_json_error(array('message' => __('Table missing.', 'ai-eshop-optimizer')));
}
$id = isset($_POST['id']) ? absint(wp_unslash($_POST['id'])) : 0;
$slug = isset($_POST['slug']) ? sanitize_key(wp_unslash($_POST['slug'])) : '';
$label = isset($_POST['label']) ? sanitize_text_field(wp_unslash($_POST['label'])) : '';
$stock_table = isset($_POST['stock_table']) ? sanitize_text_field(wp_unslash($_POST['stock_table'])) : '';
$match_col = isset($_POST['match_col']) ? sanitize_key(wp_unslash($_POST['match_col'])) : 'gtin';
$enabled = !empty($_POST['enabled']) ? 1 : 0;
$sort_order = isset($_POST['sort_order']) ? (int) $_POST['sort_order'] : 0;
$erp_id_raw = isset($_POST['erp_id']) ? trim(sanitize_text_field((string) wp_unslash($_POST['erp_id']))) : '';
$erp_id = ($erp_id_raw === '') ? null : $erp_id_raw;
$match_patterns_raw = isset($_POST['match_patterns']) ? trim(sanitize_text_field((string) wp_unslash($_POST['match_patterns']))) : '';
$match_patterns = ($match_patterns_raw === '') ? null : $match_patterns_raw;
if ($slug === '' || $label === '') {
wp_send_json_error(array('message' => __('Slug and label are required.', 'ai-eshop-optimizer')));
}
if ($stock_table === '' && $id <= 0) {
if (!class_exists('AIEO_DMM_Branch_Provisioner')) {
wp_send_json_error(array('message' => __('Rooster One Pro is not installed. The branch provisioning engine ships with the Pro plugin — install and activate it and this screen comes alive.', 'ai-eshop-optimizer')));
}
$res = AIEO_DMM_Branch_Provisioner::provision($slug, $label, array(
'sort_order' => $sort_order,
'erp_id' => $erp_id,
'match_patterns' => $match_patterns,
));
if (is_wp_error($res)) {
wp_send_json_error(array('message' => $res->get_error_message()));
}
if (!$enabled) {
$wpdb->update($table, array('enabled' => 0), array('id' => (int) $res['branch_id']));
}
wp_send_json_success(array(
'id' => (int) $res['branch_id'],
'message' => sprintf(
__('Branch provisioned: board %1$s created and seeded with %2$s products; scanner page at %3$s.', 'ai-eshop-optimizer'),
$res['board'],
number_format_i18n((int) $res['seeded']),
$res['page_url'] !== '' ? $res['page_url'] : '—'
),
));
}
if ($stock_table === '') {
wp_send_json_error(array('message' => __('This branch has no stock table named. Type one, or delete and re-create the branch to auto-provision it.', 'ai-eshop-optimizer')));
}
if (!aieo_dmm_table_exists($stock_table)) {
wp_send_json_error(array('message' => sprintf(
__('Table "%s" does not exist in the database.', 'ai-eshop-optimizer'),
$stock_table
)));
}
$data = array(
'slug' => $slug,
'label' => $label,
'stock_table' => $stock_table,
'match_col' => $match_col,
'enabled' => $enabled,
'sort_order' => $sort_order,
'erp_id' => $erp_id,
'match_patterns' => $match_patterns,
);
$formats = array('%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s');
if ($id > 0) {
$wpdb->update($table, $data, array('id' => $id), $formats, array('%d'));
} else {
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE slug = %s",
$slug
));
if ($existing) {
wp_send_json_error(array('message' => __('A branch with that slug already exists.', 'ai-eshop-optimizer')));
}
$wpdb->insert($table, $data, $formats);
$id = (int) $wpdb->insert_id;
}
self::sync_branch_artifacts();
wp_send_json_success(array('id' => $id, 'message' => __('Branch saved.', 'ai-eshop-optimizer')));
}
private static function sync_branch_artifacts() {
if (!class_exists('AIEO_DMM_Branches')) {
$f = AIEO_DIR . 'includes/dmm/services/class-aieo-dmm-branches.php';
if (is_readable($f)) { require_once $f; }
}
if (class_exists('AIEO_DMM_Branches')) {
AIEO_DMM_Branches::sync();
}
self::seed_worksheet_caps_once();
}
public function ajax_save_erp_settings() {
AIEO_DMM::verify_ajax_request();
$v = isset($_POST['eshop_invoice_type']) ? trim(sanitize_text_field((string) wp_unslash($_POST['eshop_invoice_type']))) : '';
update_option(AIEO_DMM_Branches::OPT_ESHOP_INVOICE_TYPE, $v, false);
wp_send_json_success(array('message' => __('ERP feed settings saved.', 'ai-eshop-optimizer')));
}
public function ajax_delete_branch() {
AIEO_DMM::verify_ajax_request();
$id = isset($_POST['id']) ? absint(wp_unslash($_POST['id'])) : 0;
if ($id <= 0) {
wp_send_json_error(array('message' => __('Invalid id.', 'ai-eshop-optimizer')));
}
global $wpdb;
$wpdb->delete($wpdb->prefix . 'aieo_dmm_branches', array('id' => $id), array('%d'));
self::sync_branch_artifacts();
wp_send_json_success();
}
public function ajax_preorder_set_status() {
AIEO_DMM::verify_ajax_request();
$id = isset($_POST['id']) ? absint(wp_unslash($_POST['id'])) : 0;
$status = isset($_POST['status']) ? sanitize_key(wp_unslash($_POST['status'])) : '';
$qty = isset($_POST['qty']) && $_POST['qty'] !== '' ? (int) $_POST['qty'] : null;
$result = self::apply_preorder_status($id, $status, $qty, get_current_user_id());
if (is_wp_error($result)) {
wp_send_json_error(array('message' => $result->get_error_message()));
}
wp_send_json_success($result);
}
public static function apply_preorder_status($id, $status, $qty, $user_id, array $args = array()) {
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_supplier_preorders';
if (!aieo_dmm_table_exists($table)) {
return new WP_Error('aieo_preorder_table', __('The pre-order table is missing.', 'ai-eshop-optimizer'));
}
$id = (int) $id;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM `{$table}` WHERE id = %d", $id), ARRAY_A);
if (!$row) {
return new WP_Error('aieo_preorder_missing', __('That pre-order line no longer exists.', 'ai-eshop-optimizer'));
}
$now = current_time('mysql', true);
$user_id = (int) $user_id;
switch ($status) {
case 'approved':
$patch = array('status' => 'approved', 'approved_by' => $user_id, 'approved_at' => $now);
break;
case 'ordered':
$patch = array('status' => 'ordered', 'ordered_at' => $now);
if (empty($row['approved_at'])) {
$patch['approved_by'] = $user_id;
$patch['approved_at'] = $now;
}
break;
case 'received':
$received = ($qty !== null) ? max(0, (int) $qty) : (int) $row['requested_qty'];
$patch = array(
'status' => 'received',
'received_at' => $now,
'received_qty' => $received,
'fulfilled' => 1,
'fulfilled_at' => $now,
'fulfilled_qty' => $received,
);
break;
case 'rejected':
$patch = array(
'status' => 'rejected',
'approved_by' => $user_id,
'approved_at' => $now,
'fulfilled' => 1,
'fulfilled_at' => $now,
'fulfilled_qty' => 0,
);
break;
default:
return new WP_Error('aieo_preorder_status', __('Unknown status.', 'ai-eshop-optimizer'));
}
if ('ordered' === $status && !empty($args['batch_ref'])) {
$patch['batch_ref'] = substr((string) $args['batch_ref'], 0, 32);
}
$wpdb->update($table, $patch, array('id' => $id));
self::log_preorder_event($id, array(
'user_id' => $user_id,
'source' => isset($args['source']) ? (string) $args['source'] : 'queue',
'from_status' => (string) $row['status'],
'to_status' => (string) $patch['status'],
'qty_before' => (int) $row['requested_qty'],
'qty_after' => ('received' === $status) ? (int) $patch['received_qty'] : null,
'note' => trim((isset($args['note']) ? (string) $args['note'] : '')
. (isset($patch['batch_ref']) ? ' batch ' . $patch['batch_ref'] : '')),
));
return array('id' => $id, 'status' => (string) $patch['status'], 'fulfilled' => (int) ($patch['fulfilled'] ?? (int) $row['fulfilled']));
}
public static function log_preorder_event($preorder_id, array $e) {
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_preorder_log';
if (!function_exists('aieo_dmm_table_exists') || !aieo_dmm_table_exists($table)) { return; }
$wpdb->insert($table, array(
'preorder_id' => (int) $preorder_id,
'created_at' => current_time('mysql', true),
'user_id' => isset($e['user_id']) ? (int) $e['user_id'] : (int) get_current_user_id(),
'source' => isset($e['source']) && '' !== $e['source'] ? substr((string) $e['source'], 0, 32) : 'queue',
'from_status' => isset($e['from_status']) && '' !== (string) $e['from_status'] ? substr((string) $e['from_status'], 0, 20) : null,
'to_status' => isset($e['to_status']) && '' !== (string) $e['to_status'] ? substr((string) $e['to_status'], 0, 20) : null,
'qty_before' => (isset($e['qty_before']) && null !== $e['qty_before']) ? (int) $e['qty_before'] : null,
'qty_after' => (isset($e['qty_after']) && null !== $e['qty_after']) ? (int) $e['qty_after'] : null,
'note' => isset($e['note']) && '' !== trim((string) $e['note']) ? substr(trim((string) $e['note']), 0, 255) : null,
));
}
private static function worksheet_slug_from_report($report_id) {
if (0 !== strpos($report_id, 'preorder_worksheet_')) { return ''; }
$slug = substr($report_id, strlen('preorder_worksheet_'));
return in_array($slug, self::worksheet_slugs(), true) ? $slug : '';
}
public function report_save_cell($handled, $ctx, $def) {
if (false !== $handled) { return $handled; }
$slug = self::worksheet_slug_from_report((string) $ctx['report']);
if ('' === $slug) { return $handled; }
$column = (string) $ctx['column'];
if (!in_array($column, array('existing_stock', 'desired_qty'), true)) {
return __('Only the shelf-stock and requested-quantity cells are editable.', 'ai-eshop-optimizer');
}
$value = (int) $ctx['value'];
if ($value < 0) {
return __('Quantities cannot be negative.', 'ai-eshop-optimizer');
}
$product_id = (int) $ctx['row_key'];
if ($product_id <= 0) {
return __('That row names no product.', 'ai-eshop-optimizer');
}
$branch = $this->get_branch_by_slug($slug);
if (!$branch) {
return __('This branch is not enabled, so its worksheet is read-only.', 'ai-eshop-optimizer');
}
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_preorder_worksheet';
if (!aieo_dmm_table_exists($table)) {
return __('The worksheet table is missing. Re-run the plugin schema ensure.', 'ai-eshop-optimizer');
}
$done = $wpdb->query($wpdb->prepare(
"INSERT INTO {$table} (branch_id, product_id, `{$column}`, updated_by, updated_at)
VALUES (%d, %d, %d, %d, %s)
ON DUPLICATE KEY UPDATE `{$column}` = VALUES(`{$column}`), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)",
(int) $branch['id'],
$product_id,
$value,
(int) get_current_user_id(),
current_time('mysql', true)
));
return (false === $done)
? __('The database refused that change.', 'ai-eshop-optimizer')
: true;
}
public function report_bulk($result, $ctx, $def) {
if (false !== $result) { return $result; }
$slug = self::worksheet_slug_from_report((string) $ctx['report']);
if ('' === $slug) { return $result; }
$action = (string) $ctx['action'];
if (!in_array($action, array('submit', 'clear'), true)) { return $result; }
$branch = $this->get_branch_by_slug($slug);
if (!$branch) {
return __('This branch is not enabled, so its worksheet cannot submit requests.', 'ai-eshop-optimizer');
}
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $ctx['ids']))));
if (empty($ids)) {
return array('updated' => 0, 'skipped' => 0);
}
return ('clear' === $action)
? $this->worksheet_clear((int) $branch['id'], $ids)
: $this->worksheet_submit($branch, $ids, $slug);
}
private function worksheet_clear($branch_id, array $ids) {
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_preorder_worksheet';
if (!aieo_dmm_table_exists($table)) {
return array('updated' => 0, 'skipped' => count($ids));
}
$in = implode(',', array_map('intval', $ids));
$updated = (int) $wpdb->query($wpdb->prepare(
"UPDATE {$table}
SET desired_qty = NULL, updated_by = %d, updated_at = %s
WHERE branch_id = %d AND product_id IN ({$in}) AND desired_qty IS NOT NULL",
(int) get_current_user_id(),
current_time('mysql', true),
$branch_id
));
return array('updated' => $updated, 'skipped' => count($ids) - $updated);
}
private function worksheet_submit(array $branch, array $ids, $slug) {
global $wpdb;
$wt = $wpdb->prefix . 'aieo_dmm_preorder_worksheet';
$pt = $wpdb->prefix . 'aieo_dmm_supplier_preorders';
if (!aieo_dmm_table_exists($wt) || !aieo_dmm_table_exists($pt)) {
return __('The worksheet or pre-order table is missing. Re-run the plugin schema ensure.', 'ai-eshop-optimizer');
}
$branch_id = (int) $branch['id'];
$in = implode(',', array_map('intval', $ids));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wt} WHERE branch_id = %d AND product_id IN ({$in}) AND COALESCE(desired_qty, 0) > 0",
$branch_id
), ARRAY_A);
$rows = is_array($rows) ? $rows : array();
$skipped = count($ids) - count($rows);
if (empty($rows)) {
return array('updated' => 0, 'skipped' => $skipped);
}
if (class_exists('AIEO_DMM_Supplier_Orders')) {
AIEO_DMM_Supplier_Orders::warm_supplier_memo(array_map('intval', wp_list_pluck($rows, 'product_id')));
}
$now = current_time('mysql', true);
$user_id = (int) get_current_user_id();
$updated = 0;
foreach ($rows as $w) {
$product_id = (int) $w['product_id'];
$qty = (int) $w['desired_qty'];
$existing = (null !== $w['existing_stock']) ? (int) $w['existing_stock'] : null;
$open = $wpdb->get_row($wpdb->prepare(
"SELECT id, requested_qty FROM {$pt}
WHERE product_id = %d AND branch_id = %d AND status = 'requested' AND fulfilled = 0
ORDER BY id DESC LIMIT 1",
$product_id,
$branch_id
), ARRAY_A);
if ($open) {
$request_id = (int) $open['id'];
$patch = array('requested_qty' => $qty);
if (null !== $existing) { $patch['prior_existing'] = $existing; }
$wpdb->update($pt, $patch, array('id' => $request_id));
self::log_preorder_event($request_id, array(
'user_id' => $user_id,
'source' => 'worksheet',
'from_status' => 'requested',
'to_status' => 'requested',
'qty_before' => (int) $open['requested_qty'],
'qty_after' => $qty,
'note' => 'worksheet ' . $slug . ': open request updated',
));
} else {
$supplier_id = class_exists('AIEO_DMM_Supplier_Orders')
? (int) AIEO_DMM_Supplier_Orders::resolve_supplier($product_id)
: 0;
$brand_id = null;
$brand_terms = wp_get_object_terms($product_id, 'product_brand', array('fields' => 'ids'));
if (!is_wp_error($brand_terms) && !empty($brand_terms)) {
$brand_id = (int) $brand_terms[0];
}
$wpdb->insert($pt, array(
'supplier_term_id' => $supplier_id > 0 ? $supplier_id : null,
'brand_term_id' => $brand_id,
'product_id' => $product_id,
'branch_id' => $branch_id,
'scanned_user_id' => $user_id,
'requested_qty' => $qty,
'prior_existing' => $existing,
'fulfilled' => 0,
'status' => 'requested',
'notes' => 'worksheet ' . $slug,
'created_at' => $now,
));
$request_id = (int) $wpdb->insert_id;
self::log_preorder_event($request_id, array(
'user_id' => $user_id,
'source' => 'worksheet',
'from_status' => null,
'to_status' => 'requested',
'qty_before' => null,
'qty_after' => $qty,
'note' => 'worksheet ' . $slug,
));
}
$wpdb->update($wt, array(
'desired_qty' => null,
'last_request_id' => $request_id,
'last_submitted_at' => $now,
'updated_by' => $user_id,
'updated_at' => $now,
), array('id' => (int) $w['id']));
$updated++;
}
return array(
'updated' => $updated,
'skipped' => $skipped,
'message' => sprintf(
__('%1$s requests submitted for approval; %2$s lines had no quantity and were skipped.', 'ai-eshop-optimizer'),
number_format_i18n($updated),
number_format_i18n($skipped)
),
);
}
public function shortcode_staff_scanner($atts) {
if (!is_user_logged_in()) {
return '' . esc_html__('Please log in to use the staff scanner.', 'ai-eshop-optimizer') . '
';
}
if (!self::user_can_scan()) {
return '' . esc_html__('This area is for store staff. Ask an administrator for the Rooster Stock Scanner role.', 'ai-eshop-optimizer') . '
';
}
$atts = shortcode_atts(array('branch' => '', 'fullscreen' => 'yes'), $atts, 'aieo_dmm_branch_scanner');
$locked = sanitize_key((string) $atts['branch']);
$fullscreen = ('no' !== strtolower((string) $atts['fullscreen']));
$branches = $this->fetch_enabled_branches();
if ($locked !== '') {
$branches = array_values(array_filter($branches, function ($b) use ($locked) {
return (string) $b['slug'] === $locked;
}));
if (!$branches) {
return '' . esc_html__('This branch is not available for scanning.', 'ai-eshop-optimizer') . '
';
}
}
$this->enqueue_staff_scanner_assets();
ob_start();
if ($fullscreen) {
echo '';
}
?>
';
}
?>
admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce(self::SCAN_NONCE_ACTION),
'zxingUrl' => apply_filters('aieo_scanner_zxing_url', AIEO_URI . 'assets/js/vendor/zxing-browser.min.js?ver=0.1.5'),
'i18n' => array(
'startScanner' => __('Start Scanner', 'ai-eshop-optimizer'),
'stopScanner' => __('Stop Scanner', 'ai-eshop-optimizer'),
'noCamera' => __('No camera found', 'ai-eshop-optimizer'),
'cameraError' => __('Camera error', 'ai-eshop-optimizer'),
'cameraSwitched' => __('Camera switched', 'ai-eshop-optimizer'),
'cameraSwitchFailed' => __('Could not switch camera', 'ai-eshop-optimizer'),
'startFirst' => __('Start the scanner first', 'ai-eshop-optimizer'),
'noOtherCamera' => __('No other camera', 'ai-eshop-optimizer'),
'pickBranch' => __('Select a branch first', 'ai-eshop-optimizer'),
'notFound' => __('No product found for this barcode', 'ai-eshop-optimizer'),
'alreadyLogged' => __('Already logged as missing', 'ai-eshop-optimizer'),
'loggedMissing' => __('Logged as missing', 'ai-eshop-optimizer'),
'noName' => __('(no name)', 'ai-eshop-optimizer'),
'missingCode' => __('Missing branch or code', 'ai-eshop-optimizer'),
'updateFailed' => __('Update failed', 'ai-eshop-optimizer'),
'saved' => __('Saved ✓', 'ai-eshop-optimizer'),
'networkError' => __('Network error', 'ai-eshop-optimizer'),
'introTitle' => __('Scan & place the order', 'ai-eshop-optimizer'),
'introBody' => __('Scan the barcode with your phone camera, check the product, and save stock and requested quantity.', 'ai-eshop-optimizer'),
'introStart' => __('Let\'s start', 'ai-eshop-optimizer'),
'introSkip' => __('Skip', 'ai-eshop-optimizer'),
),
));
}
public function ajax_staff_lookup() {
check_ajax_referer(self::SCAN_NONCE_ACTION, 'nonce');
if (!self::user_can_scan()) {
wp_send_json_error(array('message' => __('Forbidden.', 'ai-eshop-optimizer')));
}
$barcode = isset($_POST['barcode']) ? sanitize_text_field(wp_unslash($_POST['barcode'])) : '';
$branch_slug = isset($_POST['branch']) ? sanitize_key(wp_unslash($_POST['branch'])) : '';
if ($barcode === '' || $branch_slug === '') {
wp_send_json_error(array('message' => __('Missing barcode or branch.', 'ai-eshop-optimizer')));
}
$branch = $this->get_branch_by_slug($branch_slug);
if (!$branch) {
wp_send_json_error(array('message' => __('Unknown branch.', 'ai-eshop-optimizer')));
}
global $wpdb;
$table = $branch['stock_table'];
$match = $branch['match_col'];
if (!in_array($match, array('gtin', 'sku', 'product_id', 'barcode'), true)) {
wp_send_json_error(array('message' => __('Unsupported match column.', 'ai-eshop-optimizer')));
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE {$match} = %s LIMIT 1",
$barcode
), ARRAY_A);
if (!$row) {
wp_send_json_error(array('message' => __('Barcode not found in branch table.', 'ai-eshop-optimizer')));
}
wp_send_json_success(array(
'row' => $row,
'product' => array(
'product_id' => (int) ($row['product_id'] ?? 0),
'name' => (string) ($row['product_name'] ?? ''),
'image_url' => (string) (!empty($row['image_url']) ? $row['image_url'] : ($row['parent_image_url'] ?? '')),
'supplier_name' => (string) ($row['supplier'] ?? ''),
'existing_stock' => (int) ($row['existing_stock'] ?? 0),
'desired_qty' => (int) ($row['desired_qty'] ?? 0),
),
));
}
public function ajax_staff_update() {
check_ajax_referer(self::SCAN_NONCE_ACTION, 'nonce');
if (!self::user_can_scan()) {
wp_send_json_error(array('message' => __('Forbidden.', 'ai-eshop-optimizer')));
}
$barcode = isset($_POST['barcode']) ? sanitize_text_field(wp_unslash($_POST['barcode'])) : '';
$branch_slug = isset($_POST['branch']) ? sanitize_key(wp_unslash($_POST['branch'])) : '';
$sets = array();
if (isset($_POST['existing_stock']) || isset($_POST['desired_qty'])) {
if (isset($_POST['existing_stock'])) { $sets['existing_stock'] = max(0, (int) $_POST['existing_stock']); }
if (isset($_POST['desired_qty'])) { $sets['desired_qty'] = max(0, (int) $_POST['desired_qty']); }
} else {
$field = isset($_POST['field']) ? sanitize_key(wp_unslash($_POST['field'])) : '';
if (!in_array($field, array('existing_stock', 'desired_qty'), true)) {
wp_send_json_error(array('message' => __('Unsupported field.', 'ai-eshop-optimizer')));
}
$sets[$field] = (float) (isset($_POST['value']) ? $_POST['value'] : 0);
}
$branch = $this->get_branch_by_slug($branch_slug);
if (!$branch) {
wp_send_json_error(array('message' => __('Unknown branch.', 'ai-eshop-optimizer')));
}
if (!in_array((string) $branch['match_col'], array('gtin', 'sku', 'product_id', 'barcode'), true)) {
wp_send_json_error(array('message' => __('Unsupported match column.', 'ai-eshop-optimizer')));
}
$raw_origin = isset($_POST['origin']) ? sanitize_key(wp_unslash($_POST['origin'])) : '';
$origin = in_array($raw_origin, array('barcode_camera', 'barcode_manual'), true) ? $raw_origin : 'barcode_scan';
if (!class_exists('AIEO_DMM_Board_Writer')) {
wp_send_json_error(array('message' => __('Rooster One Pro is not installed. The stock-board write engine ships with the Pro plugin — install and activate it and scanning comes alive.', 'ai-eshop-optimizer')));
}
$res = AIEO_DMM_Board_Writer::update($branch, $barcode, $sets, $origin);
wp_send_json_success(array(
'updated' => (int) $res['updated'],
'message' => __('Stock updated.', 'ai-eshop-optimizer'),
));
}
public function shortcode_customer_scanner($atts) {
$nonce = wp_create_nonce(self::SCAN_NONCE_ACTION);
ob_start();
?>
enqueue_scanner_assets();
return ob_get_clean();
}
public function ajax_customer_lookup() {
check_ajax_referer(self::SCAN_NONCE_ACTION, 'nonce');
$barcode = isset($_POST['barcode']) ? sanitize_text_field(wp_unslash($_POST['barcode'])) : '';
if ($barcode === '' || strlen($barcode) > 64) {
wp_send_json_error(array('message' => __('Please scan a valid barcode.', 'ai-eshop-optimizer')));
}
$product_id = 0;
if (function_exists('wc_get_product_id_by_sku')) {
$product_id = (int) wc_get_product_id_by_sku($barcode);
}
if ($product_id === 0) {
global $wpdb;
$product_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key IN ('_sku','_gtin','_barcode','_global_unique_id') AND meta_value = %s LIMIT 1",
$barcode
));
}
if ($product_id <= 0 || !function_exists('wc_get_product')) {
wp_send_json_error(array('message' => __('Product not found.', 'ai-eshop-optimizer')));
}
$product = wc_get_product($product_id);
if (!$product || !$product->is_purchasable()) {
wp_send_json_error(array('message' => __('This product is not available for purchase.', 'ai-eshop-optimizer')));
}
$price = (float) $product->get_price();
if (class_exists('AIEO_DMM_Pricing_Engine')) {
$price = (float) AIEO_DMM_Pricing_Engine::instance()->get_effective_price($product_id, null, (float) $product->get_regular_price());
}
$image = wp_get_attachment_image_url($product->get_image_id(), 'medium');
wp_send_json_success(array(
'product_id' => $product_id,
'title' => $product->get_name(),
'price' => wc_price($price),
'image_url' => $image ? $image : '',
'add_to_cart_url' => $product->add_to_cart_url(),
'permalink' => get_permalink($product_id),
));
}
private function get_branch_by_slug($slug) {
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_branches';
if (!aieo_dmm_table_exists($table)) {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE slug = %s AND enabled = 1",
$slug
), ARRAY_A);
return $row;
}
public function ajax_log_missing() {
check_ajax_referer(self::SCAN_NONCE_ACTION, 'nonce');
if (!self::user_can_scan()) {
wp_send_json_error(array('message' => __('Forbidden.', 'ai-eshop-optimizer')));
}
$barcode = isset($_POST['barcode']) ? sanitize_text_field(wp_unslash($_POST['barcode'])) : '';
if ($barcode === '') {
wp_send_json_error(array('message' => __('Missing barcode.', 'ai-eshop-optimizer')));
}
$branch_slug = isset($_POST['branch']) ? sanitize_key(wp_unslash($_POST['branch'])) : '';
$branch = $branch_slug !== '' ? $this->get_branch_by_slug($branch_slug) : null;
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_branch_scan_log';
if (!aieo_dmm_table_exists($table)) {
wp_send_json_error(array('message' => __('The scan log table is missing.', 'ai-eshop-optimizer')));
}
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT id, image_url, created_at FROM {$table} WHERE barcode = %s AND status = 'missing' ORDER BY id ASC LIMIT 1",
$barcode
), ARRAY_A);
if ($existing) {
wp_send_json_success(array(
'logged' => false,
'already' => true,
'image_url' => (string) ($existing['image_url'] ?? ''),
'created_at' => (string) ($existing['created_at'] ?? ''),
));
}
$image_url = '';
$image_path = '';
if (!empty($_FILES['snapshot']['tmp_name'])) {
$uploads = wp_upload_dir();
$target_dir = rtrim((string) $uploads['basedir'], '/') . '/aieo_missing_barcodes';
if (!file_exists($target_dir)) { wp_mkdir_p($target_dir); }
$name = sanitize_file_name((string) ($_FILES['snapshot']['name'] ?? 'snapshot.jpg'));
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, array('jpg', 'jpeg', 'png', 'webp'), true)) { $ext = 'jpg'; }
$safe = preg_replace('/[^A-Za-z0-9._-]+/', '-', substr($barcode, 0, 64));
$file = 'missing-' . $safe . '-' . time() . '.' . $ext;
$dest = $target_dir . '/' . $file;
if (@move_uploaded_file($_FILES['snapshot']['tmp_name'], $dest)) {
$image_path = $dest;
$image_url = rtrim((string) $uploads['baseurl'], '/') . '/aieo_missing_barcodes/' . $file;
@chmod($dest, 0644);
}
}
$this->log_scan($barcode, 'missing', $branch ? (int) $branch['id'] : 0, array(
'image_url' => $image_url,
'image_path' => $image_path,
'user_agent' => isset($_POST['user_agent']) ? sanitize_text_field(wp_unslash($_POST['user_agent'])) : '',
));
wp_send_json_success(array('logged' => true, 'already' => false, 'image_url' => $image_url));
}
private function log_scan($barcode, $status, $branch_id = 0, array $extra = array()) {
global $wpdb;
$table = $wpdb->prefix . 'aieo_dmm_branch_scan_log';
if (!aieo_dmm_table_exists($table)) {
return;
}
$user = wp_get_current_user();
$ua = !empty($extra['user_agent'])
? substr((string) $extra['user_agent'], 0, 255)
: (isset($_SERVER['HTTP_USER_AGENT']) ? substr(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])), 0, 255) : null);
$wpdb->insert($table, array(
'barcode' => (string) $barcode,
'status' => (string) $status,
'branch_id' => $branch_id > 0 ? $branch_id : null,
'user_id' => $user && $user->ID ? (int) $user->ID : null,
'user_login' => $user && $user->ID ? (string) $user->user_login : null,
'image_url' => !empty($extra['image_url']) ? (string) $extra['image_url'] : null,
'image_path' => !empty($extra['image_path']) ? (string) $extra['image_path'] : null,
'user_agent' => $ua,
), array('%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s'));
}
private function enqueue_scanner_assets() {
wp_enqueue_script(
'aieo-dmm-scanner',
AIEO_URI . 'assets/js/dmm-scanner.js',
array('jquery'),
AIEO_VERSION,
true
);
wp_localize_script('aieo-dmm-scanner', 'aieoDmmScanner', array(
'ajaxurl' => admin_url('admin-ajax.php'),
'i18n' => array(
'cameraUnavailable' => __('Camera or BarcodeDetector unavailable in this browser.', 'ai-eshop-optimizer'),
'scanning' => __('Scanning…', 'ai-eshop-optimizer'),
'notFound' => __('Product not found.', 'ai-eshop-optimizer'),
'addToCart' => __('Add to cart', 'ai-eshop-optimizer'),
'viewProduct' => __('View product', 'ai-eshop-optimizer'),
),
));
}
}