Commit 873aadff authored by Karamel's avatar Karamel
Browse files

Add InvalidRecordException and InvalidRecordField to TicketAPI write.

The message in 'failures' from the http call holds the json representation
of the exception.
parent 5f947b02
<?php
use \Pasteque\Server\Exception\PastequeException;
use \Pasteque\Server\Model\Ticket;
use \Pasteque\Server\System\DateUtils;
use \Pasteque\Server\System\API\APICaller;
......@@ -170,7 +171,11 @@ $app->POST('/api/ticket', function ($request, $response, $args) {
// TODO: on ticket save failure, the signature of the next tickets are wrong.
// Storing the ticket as a failure and add corrections would help.
case APIResult::STATUS_CALL_REJECTED:
$tktResp['message'] = $apiRes->getContent();
if (is_a($apiRes->getContent(), PastequeException::class)) {
$tktResp['message'] = json_encode($apiRes->getContent()->toStruct());
} else {
$tktResp['message'] = $apiRes->getContent();
}
$result['failures'][] = $tktResp;
break;
case APIResult::STATUS_CALL_ERROR:
......
......@@ -22,6 +22,8 @@
namespace Pasteque\Server\API;
use \Pasteque\Server\Exception\InvalidRecordException;
use \Pasteque\Server\Exception\InvalidFieldException;
use \Pasteque\Server\Model\CashRegister;
use \Pasteque\Server\Model\CashSession;
use \Pasteque\Server\Model\FiscalTicket;
......@@ -73,14 +75,21 @@ class TicketAPI extends APIHelper implements API
$this->dao->commit();
}
/** Write a ticket and it's associated fiscal ticket. It cannot write
/**
* Write a ticket and it's associated fiscal ticket. It cannot write
* multiple tickets at once.
* @param $ticket The ticket to register (cannot be an array of tickets).
* @return The ticket after registration.
* @throws \BadMethodCallException when the ticket cannot be registered.
* A failure fiscal ticket is still registered.
* @throws \Execption When an unknown error occurs
* Nothing is registered in that case (at least it shouldn't). */
* @throws \Pasteque\Exception\InvalidRecordException when trying to
* update an existing ticket (CSTR_READ_ONLY) or writing number 0
* (CSTR_GENERATED). A failure fiscal ticket is still registered.
* @throws \Pasteque\Exception\InvalidFieldException when trying to
* associate a ticket to a non-opened cash session.
* @throws \BadMethodCall when an sql error occurs while writing the
* ticket. A failure ticket is still registered.
* @throws \Exception When an unknown error occurs while registering the
* failure ticket.
*/
public function write($ticket) {
if (get_class($ticket) != static::MODEL_NAME) {
throw new \InvalidArgumentException(sprintf('Incompatible class %s expecting %s', get_class($ticket), static::MODEL_NAME));
......@@ -90,13 +99,15 @@ class TicketAPI extends APIHelper implements API
if ($ticket->getNumber() === 0) {
$rejectReason = 'Ticket number 0 is reserved.';
$this->registerFailure($ticket, $rejectReason);
throw new \BadMethodCallException($rejectReason);
throw new InvalidRecordException(InvalidRecordException::CSTR_GENERATED,
static::MODEL_NAME, $ticket->getDictId());
}
// Overwriting an existing ticket
if (!$this->canInsert($ticket)) {
$rejectReason = 'Tickets are read only.';
$this->registerFailure($ticket, $rejectReason);
throw new \BadMethodCallException($rejectReason);
throw new InvalidRecordException(InvalidRecordException::CSTR_READ_ONLY,
static::MODEL_NAME, $ticket->getDictId());
}
// Cash session must be opened
$sessSearch = $this->dao->search(CashSession::class,
......@@ -106,11 +117,22 @@ class TicketAPI extends APIHelper implements API
if (count($sessSearch) > 0) {
$session = $sessSearch[0];
}
if ($session === null || $session->isClosed()
|| !$session->isOpened()){
if ($session === null) {
$rejectReason = 'Cash session not found.';
$this->registerFailure($ticket, $rejectReason);
$crDictId = $ticket->getDictId();
unset($crDictId['number']);
throw new InvalidFieldException(InvalidFieldException::CSTR_ASSOCIATION_NOT_FOUND,
static::MODEL_NAME, 'cashRegister&sequence',
$ticket->getDictId(), $crDictId);
} elseif ($session->isClosed() || !$session->isOpened()){
$rejectReason = 'Tickets must be assigned to an opened cash session.';
$this->registerFailure($ticket, $rejectReason);
throw new \BadMethodCallException($rejectReason);
$crDictId = $ticket->getDictId();
unset($crDictId['number']);
throw new InvalidFieldException(InvalidFieldException::CSTR_OPENED_CASH,
static::MODEL_NAME, 'cashRegister&sequence',
$ticket->getDictId(), $crDictId);
}
// Create associated fiscal ticket
$fiscalAPI = new FiscalAPI($this->dao);
......@@ -149,7 +171,7 @@ class TicketAPI extends APIHelper implements API
// a failure ticket.
$rejectReason = 'Error: ' . $e->getMessage();
$this->registerFailure($ticket, $rejectReason);
throw new \BadMethodCallException($rejectReason);
throw new \BadMethodCallException($rejectReason, 0, $e);
// If an error occurs while registering the failure, the exception
// will be propagated.
}
......
<?php
// Pastèque API
//
// Copyright (C) 2012-2015 Scil (http://scil.coop)
// Cédric Houbart, Philippe Pary
//
// This file is part of Pastèque.
//
// Pastèque is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pastèque is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Pastèque. If not, see <http://www.gnu.org/licenses/>.
namespace Pasteque\Server\Exception;
/**
* Writing a record failed because one of it's fields has an invalid value.
*/
class InvalidFieldException extends \Exception implements PastequeException
{
/** The field is read only. */
const CSTR_READ_ONLY = 'read_only_field';
/** The value must be unique. */
const CSTR_UNIQUE = 'unique_value';
/** The field is a reference to an other record but this record
* was not found. */
const CSTR_ASSOCIATION_NOT_FOUND = 'association_not_found';
/** The field is a reference to a cash session but this session
* must be opened. */
const CSTR_OPENED_CASH = 'opened_cash_required';
private $constraint;
private $class;
private $field;
private $id;
private $value;
public function __construct($constraint, $class, $field, $id, $value) {
$this->constraint = $constraint;
$this->class = $class;
$this->field = $field;
$this->id = $id;
$this->value = $value;
switch ($constraint) {
case static::CSTR_READ_ONLY:
$msg = sprintf('The field %s of %s is read only (write to update %s with value %s).',
$this->field, $this->class,
json_encode($this->getJsonableId()),
json_encode($this->getJsonableValue()));
break;
case static::CSTR_UNIQUE:
$msg = sprintf('The field %s of %s must be unique (write to update %s with value %s).',
$this->field, $this->class,
json_encode($this->getJsonableId()),
json_encode($this->getJsonableValue()));
break;
case static::CSTR_OPENED_CASH:
$msg = sprintf('The associated associated cash session in %s of %s must be opened (tried to write %s with value %s).',
$this->field, $this->class,
json_encode($this->getJsonableid()),
json_encode($this->getJsonableValue()));
break;
}
parent::__construct($msg);
}
public function getConstraint() {
return $this->constraint;
}
public function getClass() {
return $this->class;
}
public function getField() {
return $this->field;
}
public function getId() {
return $this->id;
}
public function getValue() {
return $this->value;
}
public function getJsonableId() {
if (gettype($this->id) == 'resource') {
return '<resource>';
} else {
return $this->id;
}
}
public function getJsonableValue() {
if (gettype($this->value) == 'resource') {
return '<resource>';
} else {
return $this->value;
}
}
public function toStruct() {
return [
'error' => 'InvalidFieldException',
'constraint' => $this->constraint,
'class' => $this->class,
'field' => $this->field,
'id' => $this->getJsonableId(),
'value' => $this->getJsonableValue(),
'message' => $this->message,
];
}
}
<?php
// Pastèque API
//
// Copyright (C) 2012-2015 Scil (http://scil.coop)
// Cédric Houbart, Philippe Pary
//
// This file is part of Pastèque.
//
// Pastèque is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pastèque is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Pastèque. If not, see <http://www.gnu.org/licenses/>.
namespace Pasteque\Server\Exception;
/**
* Writing a record failed because it didn't satisfy model-wide constraints.
*/
class InvalidRecordException extends \Exception implements PastequeException
{
/** The record is read only. */
const CSTR_READ_ONLY = 'read_only_record';
/** The record is generated and cannot be written directly. */
const CSTR_GENERATED = 'generated_record';
/** The requested record to update was not found at all. */
const CSTR_MAIN_RECORD_NOT_FOUND = 'main_record_not_found';
private $constraint;
private $class;
private $id;
public function __construct($constraint, $class, $id) {
$this->constraint = $constraint;
$this->class = $class;
$this->id = $id;
switch ($constraint) {
case static::CSTR_READ_ONLY:
$msg = sprintf('%s is read only (tried to update id %s).',
$this->class, json_encode($this->getJsonableId()));
break;
case static::CSTR_GENERATED:
$msg = sprintf('%s id %s is generated and cannot be written directly.',
$this->class, json_encode($this->getJsonableId()));
break;
case static::CSTR_MAIN_RECORD_NOT_FOUND:
$msg = sprintf('%s id %s was not found.',
$this->class, json_encode($this->getJsonableId()));
break;
default:
$msg = sprintf('Invalid record error on %s id %s.',
$this->class, json_encode($this->getJsonableId()));
break;
}
parent::__construct($msg);
}
public function getConstraint() {
return $this->constraint;
}
public function getClass() {
return $this->class;
}
public function getId() {
return $this->id;
}
public function getJsonableId() {
if (gettype($this->id) == 'resource') {
return '<resource>';
} else {
return $this->id;
}
}
public function toStruct() {
return [
'error' => 'InvalidRecordException',
'constraint' => $this->constraint,
'class' => $this->class,
'id' => $this->getJsonableId(),
'message' => $this->message,
];
}
}
......@@ -103,6 +103,13 @@ class Ticket extends DoctrineModel
protected $id;
public function getId() { return $this->id; }
/** Get the business id, which values are unique. */
public function getDictId() {
return ['cashRegister' => $this->cashRegister->getId(),
'sequence' => $this->sequence,
'number' => $this->number];
}
/**
* Id of a cash register
* @var int
......
......@@ -21,6 +21,8 @@
namespace Pasteque\Server\System\API;
use \Pasteque\Server\Exception\APINotFoundException;
use \Pasteque\Server\Exception\InvalidFieldException;
use \Pasteque\Server\Exception\InvalidRecordException;
use \Pasteque\Server\System\Login;
/** Utility class to call API methods. */
......@@ -68,7 +70,12 @@ class APICaller {
}
try {
$realArgs = $methodParser->buildArgsArray($args);
return APIResult::success($method->invokeArgs($api, $realArgs));
return APIResult::success($method->invokeArgs($api,
$realArgs));
} catch (InvalidRecordException $e) {
return APIResult::reject($e);
} catch (InvalidFieldException $e) {
return APIResult::reject($e);
} catch (\BadMethodCallException $e) {
return APIResult::reject($e->getMessage());
} catch (\UnexpectedValueException $e) {
......
......@@ -23,6 +23,8 @@ namespace Pasteque\Server;
use \Pasteque\Server\API\CustomerAPI;
use \Pasteque\Server\API\TicketAPI;
use \Pasteque\Server\Exception\InvalidFieldException;
use \Pasteque\Server\Exception\InvalidRecordException;
use \Pasteque\Server\Model\Category;
use \Pasteque\Server\Model\CashRegister;
use \Pasteque\Server\Model\CashSession;
......@@ -214,8 +216,6 @@ class TicketAPITest extends TestCase
}
public function testSaveTicket0() {
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Ticket number 0 is reserved.');
$line1 = TicketLine::fromStruct(['dispOrder' => 1,
'taxedUnitPrice' => 11.0,
'quantity' => 1, 'tax' => $this->tax->getId(),
......@@ -240,11 +240,25 @@ class TicketAPITest extends TestCase
'amount' => 11.0,
'currencyAmount' => 11.0], $this->dao);
$tkt->addPayment($pm1);
$exceptionThrown = false;
try {
$this->api->write($tkt);
} catch (\BadMethodCallException $e) {
} catch (InvalidRecordException $e) {
$exceptionThrown = true;
// Check the exception
$this->assertEquals(InvalidRecordException::CSTR_GENERATED,
$e->getConstraint());
$this->assertEquals(Ticket::class, $e->getClass());
$tktId = $e->getId();
$this->assertEquals($this->session->getCashRegister()->getId(),
$tktId['cashRegister']);
$this->assertEquals($this->session->getSequence(),
$tktId['sequence']);
$this->assertEquals($tkt->getNumber(), $tktId['number']);
// Check that the ticket is not saved.
$searchTkt = $this->dao->search(Ticket::class);
$this->assertEquals(0, count($searchTkt));
// Check that the failure fiscal ticket is there.
$readfTkt = $this->dao->read(FiscalTicket::class, [
'type' => FiscalTicket::TYPE_TICKET,
'sequence' => FiscalTicket::getFailureTicketSequence($tkt),
......@@ -259,8 +273,9 @@ class TicketAPITest extends TestCase
$this->assertEquals($fdata['failure'], 'Ticket number 0 is reserved.');
$this->assertTrue($readfTkt->checkSignature(null));
$this->assertTrue($readEOSTkt->checkSignature($readfTkt));
throw $e;
}
$this->assertTrue($exceptionThrown,
'Expecting InvalidRecordException.');
}
/** @depends testUpdateEOSCreate */
......@@ -311,8 +326,6 @@ class TicketAPITest extends TestCase
/** @depends testSaveTicket
* Test writing two identical tickets (without sharing references). */
public function testSaveDoubleTicket() {
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Tickets are read only.');
$line11 = TicketLine::fromStruct(['dispOrder' => 1,
'taxedUnitPrice' => 11.0,
'quantity' => 1, 'tax' => $this->tax->getId(),
......@@ -364,11 +377,25 @@ class TicketAPITest extends TestCase
'amount' => 11.0,
'currencyAmount' => 11.0], $this->dao);
$tkt2->addPayment($pm21);
$exceptionThrown = false;
try {
$this->api->write($tkt2);
} catch (\BadMethodCallException $e) {
} catch (InvalidRecordException $e) {
$exceptionThrown = true;
// Check the exception
$this->assertEquals(InvalidRecordException::CSTR_READ_ONLY,
$e->getConstraint());
$this->assertEquals(Ticket::class, $e->getClass());
$tktId = $e->getId();
$this->assertEquals($this->session->getCashRegister()->getId(),
$tktId['cashRegister']);
$this->assertEquals($this->session->getSequence(),
$tktId['sequence']);
$this->assertEquals($tkt2->getNumber(), $tktId['number']);
// Check that the ticket is untouched.
$searchTkt = $this->dao->search(Ticket::class);
$this->assertEquals(1, count($searchTkt));
// Check that the failure fiscal ticket is there.
$readfTkt = $this->dao->read(FiscalTicket::class, [
'type' => FiscalTicket::TYPE_TICKET,
'sequence' => FiscalTicket::getFailureTicketSequence($tkt1),
......@@ -384,8 +411,9 @@ class TicketAPITest extends TestCase
$this->assertEquals($fdata['failure'], 'Tickets are read only.');
$this->assertTrue($readfTkt->checkSignature(null));
$this->assertTrue($readEOSTkt->checkSignature($readfTkt));
throw $e;
}
$this->assertTrue($exceptionThrown,
'Expecting InvalidRecordException.');
}
/** @depends testSaveTicket
......@@ -420,11 +448,22 @@ class TicketAPITest extends TestCase
$exceptionThrown = false;
try {
$this->api->write($tkt);
} catch (\BadMethodCallException $e) {
} catch (InvalidRecordException $e) {
$exceptionThrown = true;
$this->assertEquals('Tickets are read only.', $e->getMessage());
// Check the exception
$this->assertEquals(InvalidRecordException::CSTR_READ_ONLY,
$e->getConstraint());
$this->assertEquals(Ticket::class, $e->getClass());
$tktId = $e->getId();
$this->assertEquals($this->session->getCashRegister()->getId(),
$tktId['cashRegister']);
$this->assertEquals($this->session->getSequence(),
$tktId['sequence']);
$this->assertEquals($tkt->getNumber(), $tktId['number']);
// Check that the ticket was untouched.
$searchTkt = $this->dao->search(Ticket::class);
$this->assertEquals(1, count($searchTkt));
// Check that the failure fiscal ticket is there.
$readfTkt = $this->dao->read(FiscalTicket::class, [
'type' => FiscalTicket::TYPE_TICKET,
'sequence' => FiscalTicket::getFailureTicketSequence($tkt),
......@@ -447,7 +486,6 @@ class TicketAPITest extends TestCase
/** @depends testSaveTicketOverwrite
* Test failing the same ticket twice to check that 2 rejects are saved. */
public function testFailTwice() {
$this->expectException(\BadMethodCallException::class);
$line1 = TicketLine::fromStruct(['dispOrder' => 1,
'taxedUnitPrice' => 11.0,
'quantity' => 1, 'tax' => $this->tax->getId(),
......@@ -476,13 +514,27 @@ class TicketAPITest extends TestCase
try {
$this->api->write($tkt);
$this->api->write($tkt);
} catch (\BadMethodCallException $e) {
$exceptionThrown = false;
} catch (InvalidRecordException $e) {
// this is expected, refail
try {
$this->api->write($tkt);
} catch (\BadMethodCallException $e) {
} catch (InvalidRecordException $e) {
$exceptionThrown = true;
// Check the exception
$this->assertEquals(InvalidRecordException::CSTR_READ_ONLY,
$e->getConstraint());
$this->assertEquals(Ticket::class, $e->getClass());
$tktId = $e->getId();
$this->assertEquals($this->session->getCashRegister()->getId(),
$tktId['cashRegister']);
$this->assertEquals($this->session->getSequence(),
$tktId['sequence']);
$this->assertEquals($tkt->getNumber(), $tktId['number']);
// Check that the ticket was untouched.
$searchTkt = $this->dao->search(Ticket::class);
$this->assertEquals(1, count($searchTkt));
// Check that the failure fiscal ticket is there.
$readfTkt = $this->dao->read(FiscalTicket::class, [
'type' => FiscalTicket::TYPE_TICKET,
'sequence' => FiscalTicket::getFailureTicketSequence($tkt),
......@@ -507,9 +559,10 @@ class TicketAPITest extends TestCase
$this->assertTrue($readfTkt->checkSignature(null));
$this->assertTrue($readfTkt2->checkSignature($readfTkt));
$this->assertTrue($readEOSTkt->checkSignature($readfTkt2));
throw $e;
}
}
$this->assertTrue($exceptionThrown,
'Expecting InvalidRecordException.');
}
/** @depends testSaveTicket
......@@ -556,8 +609,6 @@ class TicketAPITest extends TestCase
/** @depends testSaveTicket */
public function testSaveTicketClosedCash() {
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Tickets must be assigned to an opened cash session');
$sess = $this->dao->read(CashSession::class, $this->session->getId());
$sess->setCloseDate(new \DateTime('2018-01-01 8:00'));
$this->dao->write($sess); // as well, editing $this->sesssion doesn't work
......@@ -587,11 +638,26 @@ class TicketAPITest extends TestCase
'amount' => 11.0,
'currencyAmount' => 11.0], $this->dao);
$tkt->addPayment($pm1);
$exceptionThrown = false;
try {
$this->api->write($tkt);
} catch (\BadMethodCallException $e) {
} catch (InvalidFieldException $e) {
$exceptionThrown = true;
// Check the exception
$this->assertEquals(InvalidFieldException::CSTR_OPENED_CASH,
$e->getConstraint());
$this->assertEquals(Ticket::class, $e->getClass());
$tktId = $e->getId();
$this->assertEquals($this->session->getCashRegister()->getId(),
$tktId['cashRegister']);
$this->assertEquals($this->session->getSequence(),
$tktId['sequence']);
$this->assertEquals($tkt->getNumber(), $tktId['number']);
$this->assertEquals('cashRegister&sequence', $e->getField());
// Check that the ticket is not registered
$searchTkt = $this->dao->search(Ticket::class);
$this->assertEquals(0, count($searchTkt));
// Check that the failure fiscal ticket is there.
$readfTkt = $this->dao->read(FiscalTicket::class, [
'type' => FiscalTicket::TYPE_TICKET,
'sequence' => FiscalTicket::getFailureTicketSequence($tkt),
......@@ -607,12 +673,81 @@ class TicketAPITest extends TestCase
$this->assertEquals($fdata['failure'], 'Tickets must be assigned to an opened cash session.');
$this->assertTrue($readfTkt->checkSignature(null));
$this->assertTrue($readEOSTkt->checkSignature($readfTkt));
throw $e;
}
$this->assertTrue($exceptionThrown,
'Expecting InvalidFieldException.');
}
/** @depends testSaveTicket */
public function testSaveTicketCashNotOpened() {
$this->markTestIncomplete('Todo');
$session = new CashSession();
$session->setCashRegister($this->cash);
$session->setSequence(2);
$this->dao->write($session);
$this->dao->commit();
$line1 = TicketLine::fromStruct(['dispOrder' => 1,
'taxedUnitPrice' => 11.0,
'quantity' => 1, 'tax' => $this->tax->getId(),
'product' => $this->prd,
'taxRate' => 0.1,
'taxedPrice' => 11.0,
'finalTaxedPrice' => 11.0], $this->dao);
$tkt = new Ticket();
$tkt->setCashRegister($this->session->getCashRegister());
$tkt->setSequence($session->getSequence());
$tkt->setNumber(1);
$tkt->setDate(new \DateTime('2018-01-01 8:05'));
$tkt->setUser($this->user);
$tkt->addLine($line1);
$tkt->setTaxedPrice(11.0);
$tkt->setFinalPrice(10.0); $tkt->setFinalTaxedPrice(11.0);
$tax = new TicketTax();
$tax->setTax($this->tax); $tax->setBase(10.0); $tax->setAmount(1.0);
$tkt->addTax($tax);