Summary
So I set up the example_mysql_mirror and got it all working but for some reason it always overwrites the changes in MySQL regardless of qbsql_modify_timestamp. I noticed the mirroring wiki page http://www.consolibyte.com/wiki/doku.php/quickbooks_integration_php_consolibyte_sqlmirror references a different field (qbsql_modify_datetime) but just chalked that up to internal changes that were never changed in the wiki. I am updating table "qb_iteminventory" and the key "QuantityOnHand" for any/all rows.
My ultimate objective is to be able to update inventory quantity for items. The mirror example was simply a first working example that it is not only possible but easily done (probably the same reason why it was created).
Upon further digging I have come to a few conclusions, one being that the QuickBooks PHP Dev Kit example_mysql_mirror.php is not complete. I did see the WARNING and I am using the latest GitHub code.
I feel it's a problem with time (internal qbsql_modify_timestamp processing) or a parent issue (qb_iteminventory a sub-item/child of a parent that needs to be updated as well, etc.), or even a missing field (I noticed QuantityOnHand but did not see QuantityAvailable in MySQL. Probably requiring using a different report for the QB inventory exchange (not sure if this is needed?).
CODE
example_mysql_mirror.php
// I always program in E_STRICT error mode with error reporting turned on...
error_reporting(E_ALL | E_STRICT);
ini_set('display_errors', 1);
// Set the include path
require_once dirname(__FILE__) . '/../../QuickBooks.php';
// You should make sure this matches the time-zone QuickBooks is running in
if (function_exists('date_default_timezone_set'))
{
date_default_timezone_set('America/Los_Angeles');
}
// The username and password the Web Connector will use to connect with
$username = '';
$password = '';
// Database connection string
//
// You *MUST* start with a fresh database! If the database you use has any
// quickbooks_* or qb_* related tables in it, then the schema *WILL NOT* build
// correctly!
//
// Currently, only MySQL is supported/tested.
$dsn = 'mysqli://:@localhost/spray_quickbooks';
// If the database has not been initialized, we need to initialize it (create
// schema and set up the username/password, etc.)
if (!QuickBooks_Utilities::initialized($dsn))
{
header('Content-Type: text/plain');
// It takes a really long time to build the schema...
set_time_limit(0);
$driver_options = array(
);
$init_options = array(
'quickbooks_sql_enabled' => true,
);
QuickBooks_Utilities::initialize($dsn, $driver_options, $init_options);
QuickBooks_Utilities::createUser($dsn, $username, $password);
exit;
}
// What mode do we want to run the mirror in?
//$mode = QuickBooks_WebConnector_Server_SQL::MODE_READONLY; // Read from QuickBooks only (no data will be pushed back to QuickBooks)
//$mode = QuickBooks_WebConnector_Server_SQL::MODE_WRITEONLY; // Write to QuickBooks only (no data will be copied into the SQL database)
$mode = QuickBooks_WebConnector_Server_SQL::MODE_READWRITE; // Keep both QuickBooks and the database in sync, reading and writing changes back and forth)
// What should we do if a conflict is found? (a record has been changed by another user or process that we're trying to update)
$conflicts = QuickBooks_WebConnector_Server_SQL::CONFLICT_LOG;
// What should we do with records deleted from QuickBooks?
//$delete = QuickBooks_WebConnector_Server_SQL::DELETE_REMOVE; // Delete the record from the database too
$delete = QuickBooks_WebConnector_Server_SQL::DELETE_FLAG; // Just flag it as deleted
// Hooks (optional stuff)
$hooks = array();
/*
// Hooks (optional stuff)
$hook_obj = new MyHookClass2('Keith Palmer');
$hooks = array(
// Register a hook which occurs when we perform an INSERT into the SQL database for a record from QuickBooks
// QuickBooks_SQL::HOOK_SQL_INSERT => 'my_function_name_for_inserts',
// QuickBooks_SQL::HOOK_SQL_INSERT => 'MyHookClass::myMethod',
// Register a hook which occurs when we perform an UPDATE on the SQL database for a record from QuickBooks
// QuickBooks_SQL::HOOK_SQL_UPDATE => 'my_function_name_for_updates',
// Example of registering multiple hooks for one hook type
// QuickBooks_SQL::HOOK_PREHANDLE => array(
// 'my_prehandle_function',
// array( $hook_obj, 'myMethod' ),
// ),
// Example of using the hook factory to use a pre-defined hook
// QuickBooks_SQL::HOOK_SQL_INSERT => QuickBooks_Hook_Factory::create(
// 'Relay_POST', // Relay the hook data to a remote URL via a HTTP POST
// 'http://localhost:8888/your_script.php'),
QuickBooks_SQL::SQL_INSERT => array(
QuickBooks_Hook_Factory::create(
'Relay_POST',
'http://localhost:8888/your_script.php',
array( '_secret' => 'J03lsN3at@pplication' ) ),
),
);
class MyHookClass
{
static public function myMethod($requestID, $user, $hook, &$err, $hook_data, $callback_config)
{
// do something here...
return true;
}
}
function my_prehandle_function($requestID, $user, $hook, &$err, $hook_data, $callback_config)
{
//print('here we are!');
return true;
}
class MyHookClass2
{
protected $_var;
public function __construct($var)
{
$this->_var = $var;
}
public function myMethod($requestID, $user, $hook, &$err, $hook_data, $callback_config)
{
//print('variable equals: ' . $this->_var);
return true;
}
}
*/
//
$soap_options = array();
//
$handler_options = array(
'deny_concurrent_logins' => false,
'deny_reallyfast_logins' => false,
);
//
$driver_options = array();
$ops = array(
QUICKBOOKS_OBJECT_SALESTAXITEM,
QUICKBOOKS_OBJECT_SALESTAXCODE,
QUICKBOOKS_OBJECT_CUSTOMER,
QUICKBOOKS_OBJECT_VENDOR,
QUICKBOOKS_OBJECT_TEMPLATE,
QUICKBOOKS_OBJECT_CUSTOMERTYPE,
QUICKBOOKS_OBJECT_VENDORTYPE,
QUICKBOOKS_OBJECT_ESTIMATE,
QUICKBOOKS_OBJECT_INVOICE,
QUICKBOOKS_OBJECT_CLASS,
QUICKBOOKS_OBJECT_INVOICE,
QUICKBOOKS_OBJECT_INVENTORYITEM,
/* Not quite sure why these are not being used
QUICKBOOKS_OBJECT_NONINVENTORYITEM,
QUICKBOOKS_OBJECT_SERVICEITEM,
QUICKBOOKS_OBJECT_SHIPMETHOD,
QUICKBOOKS_OBJECT_PAYMENTMETHOD,
QUICKBOOKS_OBJECT_TERMS,
QUICKBOOKS_OBJECT_PRICELEVEL,
QUICKBOOKS_OBJECT_ITEM,
*/
QUICKBOOKS_OBJECT_PAYMENTMETHOD,
QUICKBOOKS_OBJECT_COMPANY,
QUICKBOOKS_OBJECT_HOST,
QUICKBOOKS_OBJECT_PREFERENCES,
);
$ops_misc = array( // For fetching inventory levels, deleted transactions, etc.
QUICKBOOKS_DERIVE_INVENTORYLEVELS,
QUICKBOOKS_QUERY_DELETEDLISTS,
QUICKBOOKS_QUERY_DELETEDTRANSACTIONS,
// 'nothing',
);
//
$sql_options = array(
'only_import' => $ops,
'only_add' => $ops,
'only_modify' => $ops,
'only_misc' => $ops_misc,
);
//
$callback_options = array();
// $dsn_or_conn, $how_often, $mode, $conflicts, $users = null,
// $map = array(), $onerror = array(), $hooks = array(), $log_level, $soap = QUICKBOOKS_SOAPSERVER_BUILTIN, $wsdl = QUICKBOOKS_WSDL, $soap_options = array(), $handler_options = array(), $driver_options = array()
$Server = new QuickBooks_WebConnector_Server_SQL(
$dsn,
'1 minute',
$mode,
$conflicts,
$delete,
$username,
array(),
array(),
$hooks,
QUICKBOOKS_LOG_DEVELOP,
QUICKBOOKS_SOAPSERVER_BUILTIN,
QUICKBOOKS_WSDL,
$soap_options,
$handler_options,
$driver_options,
$sql_options,
$callback_options);
$Server->handle(true, true);
save.php
<?php
if (count($_POST) == 0) {
dErr("There is nothing to save. Try again later.");
}
// Compile list of rows to update
$updates = array();
foreach ($_POST as $key => $value) {
array_push($updates, array(explode('_', $key)[1], $value));
}
unset($key);
unset($value);
// TODO: Database updating
// IMPORTANT: Add validation and other selective functionality before adding saving
$qb = new mysqli('127.0.0.1', '', '', 'spray_quickbooks');
// Oh no! A connect_errno exists so the connection attempt failed!
if ($qb->connect_errno) {
dErr("Error: Failed to make a MySQL connection, here is why: <br />Errno: " . $qb->connect_errno . "<br />Error: " . $qb->connect_error);
}
$qbe = new mysqli('127.0.0.1', '', '', 'spray_qb_extras');
// Oh no! A connect_errno exists so the connection attempt failed!
if ($qbe->connect_errno) {
dErr("Error: Failed to make a MySQL connection, here is why: <br />Errno: " . $qbe->connect_errno . "<br />Error: " . $qbe->connect_error);
}
foreach ($updates as $update) {
// Perform an SQL query
$sql = "UPDATE qb_iteminventory SET QuantityOnHand='" . $update[1] . "' WHERE qbsql_id='" . $update[0] . "'";
if (!$qb_result = $qb->query($sql)) {
dErr("Error: Our query failed to execute and here is why: <br />Query: " . $sql . "<br />Errno: " . $qb->errno . "<br />Error: " . $qb->error);
}
}
unset($updates);
unset($update);
// Redirect back to where they came from
echo "<meta http-equiv=\"refresh\" content=\"0;url=".$_SERVER['HTTP_REFERER']."\"/>";
// Extra Functions
function dErr($msg) {
echo "<center><b>Sorry, we have encountered an error.</b><br /><br />";
echo $msg;
echo "</center>";
exit;
}
?>
Updated save.php (Inventory Adjustments)
<?php
if (count($_POST) == 0) {
dErr("There is nothing to save. Please try again later.");
}
// Compile list of rows to update
$updates = array();
foreach ($_POST as $key => $value) {
array_push($updates, array(explode('_', $key)[1], $value));
}
/*unset($key);
unset($value);*/
// IMPORTANT: Add validation and other selective functionality before adding saving
// and stop saving unchanged items just because we can
$qb = new mysqli('127.0.0.1', '', '', 'spray_quickbooks');
// Oh no! A connect_errno exists so the connection attempt failed!
if ($qb->connect_errno) {
dErr("Error: Failed to make a MySQL connection, here is why: <br />Errno: " . $qb->connect_errno . "<br />Error: " . $qb->connect_error);
}
// IMPORTANT: ONLY UPDATE CHANGED ROWS. WE DONT WANT INVENTORY ADJUSTMENTS FOR UNCHANGED ITEMS!
foreach ($updates as $update) {
// Update QuantityOnHand still so our web interface can easily see the new quantity before QB sync
$sql = "UPDATE qb_iteminventory SET QuantityOnHand='" . $update[1] . "' WHERE qbsql_id='" . $update[0] . "'";
if (!$qb_result = $qb->query($sql)) {
dErr("Error: Our query failed to execute and here is why: <br />Query: " . $sql . "<br />Errno: " . $qb->errno . "<br />Error: " . $qb->error);
}
// Get a newly updated item so we can extract Item's FullName
$sql = "SELECT * FROM qb_iteminventory WHERE qbsql_id='" . $update[0] . "'";
if (!$qb_result = $qb->query($sql)) {
dErr("Error: Our query failed to execute and here is why: <br />Query: " . $sql . "<br />Errno: " . $qbe->errno . "<br />Error: " . $qbe->error);
}
$row = mysqli_fetch_assoc($qb_result);
//print_r($row);
// Generate unique TxnID
// Apparently QuickBooks will overwrite it with the permanent TxnID when it syncs
$tID = rand(1000, 9999);
// Insert new Item Adjustment
$sql = "INSERT INTO `qb_inventoryadjustment` ( `TxnID`, `TimeCreated`, `TimeModified`, `Account_FullName`, `TxnDate`, `RefNumber`, `Memo`, `qbsql_discov_datetime`, `qbsql_resync_datetime`, `qbsql_modify_timestamp` ) VALUES ( 'TxnID-" . $tID . "', now(), now(), 'Inventory Adjustments', CURDATE(), '" . $tID . "', NULL, NULL, NULL, now() )";
if (!$qb_result = $qb->query($sql)) {
dErr("Error: Our query failed to execute and here is why: <br />Query: " . $sql . "<br />Errno: " . $qb->errno . "<br />Error: " . $qb->error);
}
// Insert new Item Adjustment Line
$sql = "INSERT INTO `qb_inventoryadjustment_inventoryadjustmentline` ( `InventoryAdjustment_TxnID`, `SortOrder`, `TxnLineID`, `Item_FullName`, `QuantityAdjustment_NewQuantity` ) VALUES ( 'TxnID-" . $tID . "', '0', 'TxnLID-" . $tID . "', '" . $row['FullName'] . "', " . $update[1] . ");";
if (!$qb_result = $qb->query($sql)) {
dErr("Error: Our query failed to execute and here is why: <br />Query: " . $sql . "<br />Errno: " . $qb->errno . "<br />Error: " . $qb->error);
}
}
// TODO: Research whether this is really required, and to what extent
// INFO: Not sure why I feel like this is important
/*$qb_result->free();
$qb->close();
unset($updates);
unset($update);
unset($sql);*/
// Redirect back to where they came from
//echo "<meta http-equiv=\"refresh\" content=\"0;url=".$_SERVER['HTTP_REFERER']."\"/>";
// Extra Functions
function dErr($msg) {
echo "<center><b>Sorry, we have encountered an error.</b><br /><br />";
echo $msg;
echo "</center>";
exit;
}
?>
SQL Mirror for Inventory Adjustments:
First, the usual disclaimer - the SQL mirror stuff is beta, so don't expect 100% functionality (as indicated in the release notes). With that said...
QuickBooks does not let you update quantities directly by editing the inventory item. If you go into the QuickBooks UI you will see this same behavior there as well.
So something like this will not work:
The above query would be telling QuickBooks to just replace the given quantity on hand, which really doesn't work in the "accounting world" because inventory changes have tax implications (businesses pay tax on the amount of inventory they carry), revenue implications (a change in inventory generally means you either bought more items from a manufacturer, or sold an item to a customer), and physical implications (the quantity or something changed, which means a physical product went to a customer, etc.) that accountants/businesses need to keep very close track of and have audit logs showing how and why the quantity changed when it did.
Instead, the way you change inventory in QuickBooks is via separate transactions. For example:
Invoice
would reduce quantity on hand (you sold something)Item Receipt
would increase quantity on hand (you bought something from a manufacturer or vendor)Inventory Adjustment
The QuickBooks SDK (and thus the mirror code) follows this convention -- if you want to change quantities, you need to create a transaction.
You probably want to create an
Inventory Adjustment
(theqb_inventoryadjustment
SQL table). Something like this:If you look in the QuickBooks UI, you'll see that
Inventory Adjustment
transactions have both base detail (reference number, date, etc.) and line-item level detail (items and quantities), so you have to make sure you supply both (theqb_inventoryadjustment
table for the base detail and theqb_inventoryadjustment_inventoryadjustmentline
table for the lines)Make sure in your
$ops
array you haveInventory Adjustments
enabled:The
QUICKBOOKS_OBJECT_INVENTORYITEM
only syncs the actual product itself, and will not sync actual quantity changes of the product.If you find the SQL Mirror doesn't work for you for whatever reason...:
The SQL mirror stuff is an experiment, and it doesn't always work properly. Boo. :-(
However, there's a great alternative if you don't mind writing a little code (and hey, you're on StackOverflow, so you don't!).
Instead, follow the quick-start linked to by the GitHub project:
Basically, you'll end up pointing the Web Connector at something like this:
Which you can then adapt to send
InventoryAdjustmentAdd
transactions pretty easily by:QUICKBOOKS_ADD_INVENTORYADJUSTMENT => array( '_quickbooks_inventoryadjustment_add_request', '_quickbooks_inventoryadjustment_add_response' )
to$map
-
Those functions ^^^ do essentially the same thing the SQL mirror attempts to do, but they are a little more controllable and tweakable than the SQL mirror stuff is since you have complete control over the qbXML there.
Remember if you go this route, you need to queue up the request whenever you want to trigger it to send to QuickBooks:
Finally:
I would highly recommend you familiarize yourself as much as possible with the QuickBooks UI. The SDK requests VERY closely match the UI, so it's very beneficial to know how to do things in the UI.