From b4ff0e525377ed933feeeb501b43c7fdf1cbb4ff Mon Sep 17 00:00:00 2001 From: Tsvetozar Bonev Date: Tue, 18 Sep 2018 14:40:52 +0300 Subject: [PATCH] pos-print: add async receipt printing --- docs/swagger/api.yaml | 148 ++++++++---- pos-print/build.gradle | 9 +- .../clouway/pos/print/PosPrintService.java | 6 + .../pos/print/adapter/db/PersistentModule.kt | 3 + .../adapter/db/PersistentReceiptRepository.kt | 224 ++++++++++++++++++ .../pos/print/adapter/http/HttpModule.java | 1 + .../pos/print/adapter/http/PrintServiceV2.kt | 97 ++++++++ .../core/BackgroundReceiptPrintingService.kt | 76 ++++++ .../com/clouway/pos/print/core/CoreModule.kt | 3 + .../pos/print/core/InMemoryPrintQueue.kt | 21 ++ .../com/clouway/pos/print/core/PrintQueue.kt | 23 ++ .../pos/print/core/PrintingListener.kt | 17 ++ .../pos/print/core/ReceiptPrintingListener.kt | 16 ++ .../pos/print/core/ReceiptRepository.kt | 105 ++++++++ .../clouway/pos/print/core/ReceiptRequest.kt | 13 + .../print/adapter/http/PrintServiceV2Test.kt | 142 +++++++++++ .../BackgroundReceiptPrintingServiceTest.kt | 152 ++++++++++++ .../PersistentReceiptRepositoryTest.kt | 206 ++++++++++++++++ 18 files changed, 1210 insertions(+), 52 deletions(-) create mode 100644 pos-print/src/main/java/com/clouway/pos/print/adapter/db/PersistentReceiptRepository.kt create mode 100644 pos-print/src/main/java/com/clouway/pos/print/adapter/http/PrintServiceV2.kt create mode 100644 pos-print/src/main/java/com/clouway/pos/print/core/BackgroundReceiptPrintingService.kt create mode 100644 pos-print/src/main/java/com/clouway/pos/print/core/InMemoryPrintQueue.kt create mode 100644 pos-print/src/main/java/com/clouway/pos/print/core/PrintQueue.kt create mode 100644 pos-print/src/main/java/com/clouway/pos/print/core/PrintingListener.kt create mode 100644 pos-print/src/main/java/com/clouway/pos/print/core/ReceiptPrintingListener.kt create mode 100644 pos-print/src/main/java/com/clouway/pos/print/core/ReceiptRepository.kt create mode 100644 pos-print/src/main/java/com/clouway/pos/print/core/ReceiptRequest.kt create mode 100644 pos-print/src/test/java/com/clouway/pos/print/adapter/http/PrintServiceV2Test.kt create mode 100644 pos-print/src/test/java/com/clouway/pos/print/core/BackgroundReceiptPrintingServiceTest.kt create mode 100644 pos-print/src/test/java/com/clouway/pos/print/persistent/PersistentReceiptRepositoryTest.kt diff --git a/docs/swagger/api.yaml b/docs/swagger/api.yaml index 9861d2a..fd80014 100644 --- a/docs/swagger/api.yaml +++ b/docs/swagger/api.yaml @@ -109,7 +109,46 @@ paths: Problem occured while printing the receipt schema: $ref: "#/definitions/RequestTimeoutError" - + + /v2/receipts/req/print: + post: + summary: Print Receipts + parameters: + - in: body + name: request + description: Prining receipts and fiscal receipts + required: true + schema: + $ref: '#/definitions/PrintReceiptRequest' + description: Saving receipts to be pritned asynchronously + responses: + 200: + description: Receipt saved and queued to be printed + schema: + type: string + example: "receiptId" + 400: + description: + The requested receipt is already saved + + /v2/receipts/req/print/status/{id}: + get: + summary: Get receipt status + parameters: + - in: path + name: id + type: string + required: true + description: The id of the receipt + responses: + 200: + description: Receipt found + schema: + type: string + example: "PRINTING" + 404: + description: Receipt not found + /v1/reports/operator/print: post: summary: Prints Report @@ -194,6 +233,10 @@ definitions: PrintReceiptRequest: type: object properties: + receiptId: + type: string + description: Unique id representing the receipt + example: "receiptId" sourceIp: type: string description: Unique ip representing the owner of the device. @@ -206,53 +249,60 @@ definitions: type: boolean description: true means fiscal receipt, false means text receipt: - type: object - description: Represents receipt item - properties: - receiptId: - type: string - description: Receipt ID - example: "123" - currency: - type: string - description: Currency - example: "USD" - prefixLines: - type: array - items: - type: string - suffixLines: - type: array - items: - type: string - amount: - type: number - format: double - description: amount to be paid - example: 1.0 - receiptItems: - type: object - description: Represents receipt item - properties: - name: - type: string - description: Name of the item - example: - quantity: - type: number - format: double - description: Quantity of the item - example: 2.0 - price: - type: number - format: double - description: Price of the item (with vat) - example: 1.0 - vat: - type: number - format: double - description: VAT - example: 20.0 + $ref: '#/definitions/Receipt' + Receipt: + type: object + description: Represents receipt item + properties: + receiptId: + type: string + description: Receipt ID + example: "123" + currency: + type: string + description: Currency + example: "USD" + prefixLines: + type: array + items: + type: string + suffixLines: + type: array + items: + type: string + amount: + type: number + format: double + description: amount to be paid + example: 1.0 + receiptItems: + type: array + items: + $ref: '#/definitions/ReceiptItem' + ReceiptItem: + type: object + description: Represents receipt item + properties: + name: + type: string + description: Name of the item + example: + quantity: + type: number + format: double + description: Quantity of the item + example: 2.0 + price: + type: number + format: double + description: Price of the item (with vat) + example: 1.0 + vat: + type: number + format: double + description: VAT + example: 20.0 + PrintReceiptResponse: type: object properties: @@ -352,4 +402,4 @@ definitions: properties: message: type: string - example: 'Printer request timeout. Unable to get response after 50 retries' \ No newline at end of file + example: 'Printer request timeout. Unable to get response after 50 retries' diff --git a/pos-print/build.gradle b/pos-print/build.gradle index b2f8ec6..9600381 100644 --- a/pos-print/build.gradle +++ b/pos-print/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.1.1' + ext.kotlin_version = '1.2.71' repositories { mavenCentral() jcenter() @@ -83,11 +83,14 @@ dependencies { compile 'org.slf4j:slf4j-api:1.6.3' compile 'ch.qos.logback:logback-classic:0.9.30' + //GuavaServices + compile group: 'com.google.guava', name: 'guava', version: '19.0' + //MongoDatabase - compile 'org.mongodb:mongodb-driver:3.4.2' + compile 'org.mongodb:mongodb-driver:3.6.4' //FongoDatabase - compile 'com.github.fakemongo:fongo:2.0.9' + compile 'com.github.fakemongo:fongo:2.2.0-RC2' compile 'com.github.spullara.cli-parser:cli-parser:1.1.2' compile 'com.google.code.gson:gson:2.8.0' diff --git a/pos-print/src/main/java/com/clouway/pos/print/PosPrintService.java b/pos-print/src/main/java/com/clouway/pos/print/PosPrintService.java index 0fcee30..094e23d 100644 --- a/pos-print/src/main/java/com/clouway/pos/print/PosPrintService.java +++ b/pos-print/src/main/java/com/clouway/pos/print/PosPrintService.java @@ -1,5 +1,6 @@ package com.clouway.pos.print; +import com.clouway.pos.print.core.BackgroundReceiptPrintingService; import com.clouway.pos.print.adapter.http.HttpBackend; import com.clouway.pos.print.adapter.http.HttpModule; import com.clouway.pos.print.core.CoreModule; @@ -39,15 +40,20 @@ public static void main(String[] args) { new PersistentModule(client, commandCLI.dbName()) ); + HttpBackend backend = new HttpBackend(commandCLI.httpPort(), injector); backend.start(); + BackgroundReceiptPrintingService backgroundPrinter = injector.getInstance(BackgroundReceiptPrintingService.class); + backgroundPrinter.startAsync().awaitRunning(); + System.out.printf("POS Print Service is up and running on port: %d\n", commandCLI.httpPort()); Runtime.getRuntime().addShutdownHook(new Thread(() -> { System.out.println("POS Print Service is going to shutdown."); try { backend.stop(); + backgroundPrinter.stopAsync(); } catch (Exception e) { System.out.println("Failed to stop server due: " + e.getMessage()); } diff --git a/pos-print/src/main/java/com/clouway/pos/print/adapter/db/PersistentModule.kt b/pos-print/src/main/java/com/clouway/pos/print/adapter/db/PersistentModule.kt index 408e824..8cbc6b8 100644 --- a/pos-print/src/main/java/com/clouway/pos/print/adapter/db/PersistentModule.kt +++ b/pos-print/src/main/java/com/clouway/pos/print/adapter/db/PersistentModule.kt @@ -1,5 +1,7 @@ package com.clouway.pos.print.adapter.db +import com.clouway.pos.print.core.PrintingListener +import com.clouway.pos.print.core.ReceiptRepository import com.google.inject.AbstractModule import com.google.inject.Provides import com.google.inject.Singleton @@ -13,6 +15,7 @@ class PersistentModule(private val client: MongoClient, private val databaseName override fun configure() { bind(CashRegisterRepository::class.java).to(PersistentCashRegisterRepository::class.java).`in`(Singleton::class.java) + bind(ReceiptRepository::class.java).to(PersistentReceiptRepository::class.java).`in`(Singleton::class.java) } @Provides diff --git a/pos-print/src/main/java/com/clouway/pos/print/adapter/db/PersistentReceiptRepository.kt b/pos-print/src/main/java/com/clouway/pos/print/adapter/db/PersistentReceiptRepository.kt new file mode 100644 index 0000000..8be043d --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/adapter/db/PersistentReceiptRepository.kt @@ -0,0 +1,224 @@ +package com.clouway.pos.print.adapter.db + +import com.clouway.pos.print.core.* +import com.google.inject.Inject +import com.google.inject.Provider +import com.mongodb.client.MongoCollection +import com.mongodb.client.MongoDatabase +import com.mongodb.client.model.Filters.and +import com.mongodb.client.model.Filters.eq +import com.mongodb.client.model.Indexes +import com.mongodb.client.model.Updates.set +import org.bson.Document +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.Optional +import java.util.UUID + +/** + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +class PersistentReceiptRepository @Inject constructor(private val database: Provider, + private val printingListener: PrintingListener) + : ReceiptRepository { + private val collectionName: String = "receipts" + + /** + * Creates an index on the requestId, isFiscal and receiptId fields. + */ + init { + database.get().getCollection(collectionName) + .createIndex(Indexes.ascending("requestId", "receiptId", "isFiscal")) + } + + override fun register(receiptRequest: ReceiptRequest): String { + val receipt = receiptRequest.receipt + + if (receipts() + .find(and( + eq("receiptId", receipt.receiptId), + eq("isFiscal", receiptRequest.isFiscal))).firstOrNull() != null) + throw ReceiptAlreadyRegisteredException() + + val receiptDoc = receiptRequest.toDocument() + + val requestId = UUID.randomUUID().toString() + + receiptDoc["requestId"] = requestId + + receipts().insertOne(receiptDoc) + + return requestId + } + + override fun getByRequestId(receiptRequestId: String): Optional { + val receiptDoc = receipts().find(and( + eq("requestId", receiptRequestId))).firstOrNull() + ?: return Optional.empty() + + return Optional.of(receiptDoc.toReceipt()) + } + + override fun getReceiptPrintRequest(requestId: String): ReceiptRequest { + val receiptDoc = receipts().find(and( + eq("requestId", requestId))).firstOrNull() + ?: throw ReceiptNotRegisteredException() + + return ReceiptRequest( + receiptDoc.toReceipt(), + receiptDoc.getString("sourceIp"), + receiptDoc.getString("operatorId"), + receiptDoc.getBoolean("isFiscal"), + requestId + ) + } + + override fun getLatest(limit: Int): Map { + val receiptMap = mutableMapOf() + + receipts().find() + .sort(Document("creationSeconds", -1)) + .limit(limit) + .toList() + .forEach { + receiptMap[it.getString("requestId")] = it.toReceipt() + } + + return receiptMap + } + + override fun getByOperatorId(operatorId: String): List { + val docList = receipts().find( + eq("operatorId", operatorId)).toList() + + val receiptList = mutableListOf() + + docList.forEach { + receiptList.add(it.toReceipt()) + } + + return receiptList + } + + override fun getBySourceIp(sourceIp: String): List { + val docList = receipts().find( + eq("sourceIp", sourceIp)).toList() + + val receiptList = mutableListOf() + + docList.forEach { + receiptList.add(it.toReceipt()) + } + + return receiptList + } + + override fun getStatus(receiptRequestId: String): PrintStatus { + val receiptDoc = receipts().find(eq("requestId", receiptRequestId)).firstOrNull() + ?: throw ReceiptNotRegisteredException() + + return PrintStatus.valueOf(receiptDoc.getString("status")) + } + + override fun getByStatus(receiptStatus: PrintStatus): List { + val receiptDocs = receipts() + .find(eq("status", receiptStatus.name)).toList() + + val receiptList = mutableListOf() + + receiptDocs.forEach { + receiptList.add(it.toReceipt()) + } + + return receiptList + } + + override fun finishPrinting(requestId: String): Receipt { + val receiptDoc = receipts().findOneAndUpdate( + eq("requestId", requestId), + set("status", PrintStatus.PRINTED.name)) + ?: throw ReceiptNotRegisteredException() + + printingListener.onPrinted(receiptDoc.toReceipt(), PrintStatus.PRINTED) + + return receiptDoc.toReceipt() + } + + override fun failPrinting(requestId: String): Receipt { + val receiptDoc = receipts().findOneAndUpdate( + eq("requestId", requestId), + set("status", PrintStatus.FAILED.name)) + ?: throw ReceiptNotRegisteredException() + + printingListener.onPrinted(receiptDoc.toReceipt(), PrintStatus.FAILED) + + return receiptDoc.toReceipt() + } + + private fun receipts(): MongoCollection { + return database.get().getCollection(collectionName) + } + + private fun ReceiptRequest.toDocument(): Document { + val receiptDoc = this.receipt.toDocument() + + receiptDoc["sourceIp"] = this.sourceIp + receiptDoc["operatorId"] = this.operatorId + receiptDoc["isFiscal"] = this.isFiscal + receiptDoc["creationSeconds"] = LocalDateTime.now().toInstant(ZoneOffset.UTC).epochSecond + + return receiptDoc + } + + @Suppress("UNCHECKED_CAST") + private fun Document.toReceipt(): Receipt { + val receipt = Receipt.Builder() + .withReceiptId(this.getString("receiptId")) + .withAmount(this.getDouble("amount")) + .currency(this.getString("currency")) + .prefixLines(this["prefixLines"] as List) + .suffixLines(this["suffixLines"] as List) + + val itemList = this["receiptItems"] as List + + itemList.forEach { + receipt.addItem(it.toReceiptItem()) + } + + return receipt.build() + } + + private fun Receipt.toDocument(): Document { + val itemList = this.receiptItems + + val docList = mutableListOf() + + itemList.forEach { + docList.add(it.toDocument()) + } + + return Document() + .append("receiptId", this.receiptId) + .append("amount", this.amount) + .append("prefixLines", this.prefixLines()) + .append("suffixLines", this.suffixLines()) + .append("currency", this.currency) + .append("receiptItems", docList) + .append("status", PrintStatus.PRINTING.name) + } + + private fun Document.toReceiptItem(): ReceiptItem { + return ReceiptItem.newItem() + .name(this.getString("name")) + .price(this.getDouble("price")) + .quantity(this.getDouble("quantity")) + .build() + } + + private fun ReceiptItem.toDocument(): Document { + return Document() + .append("name", this.name) + .append("price", this.price) + .append("quantity", this.quantity) + } +} \ No newline at end of file diff --git a/pos-print/src/main/java/com/clouway/pos/print/adapter/http/HttpModule.java b/pos-print/src/main/java/com/clouway/pos/print/adapter/http/HttpModule.java index f898e2f..d29847a 100644 --- a/pos-print/src/main/java/com/clouway/pos/print/adapter/http/HttpModule.java +++ b/pos-print/src/main/java/com/clouway/pos/print/adapter/http/HttpModule.java @@ -17,6 +17,7 @@ protected void configure() { protected void configureSitebricks() { at("/_status").serve(StatusService.class); at("/v1/receipts/req/print").serve(PrintService.class); + at("/v2/receipts/req/print").serve(PrintServiceV2.class); at("/v1/devices").serve(DeviceConfigurationService.class); at("/v1/reports").serve(ReportService.class); } diff --git a/pos-print/src/main/java/com/clouway/pos/print/adapter/http/PrintServiceV2.kt b/pos-print/src/main/java/com/clouway/pos/print/adapter/http/PrintServiceV2.kt new file mode 100644 index 0000000..34d943a --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/adapter/http/PrintServiceV2.kt @@ -0,0 +1,97 @@ +package com.clouway.pos.print.adapter.http + +import com.clouway.pos.print.core.PrintQueue +import com.clouway.pos.print.core.Receipt +import com.clouway.pos.print.core.ReceiptAlreadyRegisteredException +import com.clouway.pos.print.core.ReceiptNotRegisteredException +import com.clouway.pos.print.core.ReceiptRepository +import com.clouway.pos.print.core.ReceiptRequest +import com.clouway.pos.print.transport.GsonTransport +import com.google.gson.Gson +import com.google.inject.name.Named +import com.google.sitebricks.At +import com.google.sitebricks.headless.Reply +import com.google.sitebricks.headless.Request +import com.google.sitebricks.headless.Service +import com.google.sitebricks.http.Get +import com.google.sitebricks.http.Post +import org.slf4j.LoggerFactory +import javax.inject.Inject + +/** + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +@Service +@At("/v2/receipts/req/print") +class PrintServiceV2 @Inject constructor(private var repository: ReceiptRepository, + private var queue: PrintQueue) { + private val logger = LoggerFactory.getLogger(PrintServiceV2::class.java) + + @Post + fun printReceipt(request: Request): Reply<*> { + return try { + val dto = request.read(ReceiptDTO::class.java).`as`(GsonTransport::class.java) + + val receiptRequest = ReceiptRequest(dto.receipt, dto.sourceIp, dto.operatorId, dto.fiscal) + + val requestId = repository.register(receiptRequest) + + queue.queue(receiptRequest.copy(requestId = requestId)) + + logger.info("Receipt with request id $requestId queued for printing") + Reply.with(requestId).status(202) + } catch (ex: ReceiptAlreadyRegisteredException) { + logger.error("Receipt is already in queue") + Reply.saying().badRequest() + } + } + + @Get + @At("/status/:requestId") + fun getReceiptStatus(@Named("requestId") requestId: String): Reply<*> { + return try { + val receiptStatus = repository.getStatus(requestId) + logger.info("Receipt status returned as ${receiptStatus.name}") + Reply.with(receiptStatus).ok() + } catch (ex: ReceiptNotRegisteredException) { + logger.error("Receipt with request id $requestId was not found") + Reply.saying().notFound() + } + } + + @Get + @At("/receipts/:limit") + fun getLatestReceipts(@Named("limit") limit: Int): Reply<*> { + val receipts = repository.getLatest(limit) + + val receiptStatuses = mutableMapOf() + + receipts.forEach{ + receiptStatuses[it.key] = repository.getStatus(it.key).name + } + + val receiptList = mutableListOf() + + receiptStatuses.forEach{ + receiptList.add(ReceiptWithStatusDTO(it.value, it.key, receipts[it.key]!!)) + } + + return Reply.with(receiptList).ok() + } + + @Post + @At("/requeue/:requestId") + fun requeueReceipt(@Named("requestId") requestId: String): Reply<*> { + return try { + val receiptPrintRequest = repository.getReceiptPrintRequest(requestId) + queue.queue(receiptPrintRequest) + Reply.saying().ok() + } catch (e: ReceiptNotRegisteredException) { + logger.error("Receipt with request id $requestId was not found") + Reply.saying().notFound() + } + } + + internal data class ReceiptWithStatusDTO(val status: String, val requestId: String, val receipt: Receipt) + internal data class ReceiptDTO(val sourceIp: String = "", val operatorId: String = "", val fiscal: Boolean = false, val receipt: Receipt = Receipt.newReceipt().build()) +} \ No newline at end of file diff --git a/pos-print/src/main/java/com/clouway/pos/print/core/BackgroundReceiptPrintingService.kt b/pos-print/src/main/java/com/clouway/pos/print/core/BackgroundReceiptPrintingService.kt new file mode 100644 index 0000000..b71bdc4 --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/core/BackgroundReceiptPrintingService.kt @@ -0,0 +1,76 @@ +package com.clouway.pos.print.core + +import com.clouway.pos.print.printer.Status +import com.google.common.util.concurrent.AbstractExecutionThreadService +import com.google.inject.Inject +import org.slf4j.LoggerFactory +import java.io.IOException + +/** + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +class BackgroundReceiptPrintingService @Inject constructor(private var repository: ReceiptRepository, + private var factory: PrinterFactory, + private var queue: PrintQueue) + : AbstractExecutionThreadService() { + + override fun run() { + printReceipts() + } + + private val logger = LoggerFactory.getLogger(BackgroundReceiptPrintingService::class.java) + + fun printReceipts() { + var nextReceiptRequest: ReceiptRequest? = queue.next() + + while (nextReceiptRequest != null) { + + val receipt = nextReceiptRequest.receipt + + var printer: ReceiptPrinter? = null + + try { + printer = factory.getPrinter(nextReceiptRequest.sourceIp) + + val printResponse = if (nextReceiptRequest.isFiscal) printer.printFiscalReceipt(receipt) + else printer.printReceipt(receipt) + + if (printResponse.warnings.contains(Status.FISCAL_RECEIPT_IS_OPEN) + || printResponse.warnings.contains(Status.NON_FISCAL_RECEIPT_IS_OPEN)) { + logger.info("Receipt printing accepted") + logger.info(printResponse.warnings.toString()) + repository.finishPrinting(nextReceiptRequest.requestId) + } else { + logger.info("Receipt printing rejected") + logger.info(printResponse.warnings.toString()) + repository.failPrinting(nextReceiptRequest.requestId) + } + + + }catch (ex: ReceiptNotRegisteredException) { + logger.warn("Receipt with request id ${nextReceiptRequest.requestId} was not found in queue") + }catch (ex: DeviceNotFoundException) { + logger.warn("Device was not found") + repository.failPrinting(nextReceiptRequest.requestId) + }catch (ex: IOException){ + logger.warn("Printer threw IO Exception") + repository.failPrinting(nextReceiptRequest.requestId) + }finally { + printer?.close() + nextReceiptRequest = queue.next() + } + } + } + + override fun startUp() { + logger.info("Starting background printing service") + + super.startUp() + } + + override fun triggerShutdown() { + logger.info("Stopping background printing service") + + super.triggerShutdown() + } +} diff --git a/pos-print/src/main/java/com/clouway/pos/print/core/CoreModule.kt b/pos-print/src/main/java/com/clouway/pos/print/core/CoreModule.kt index 5b83942..8e344e3 100644 --- a/pos-print/src/main/java/com/clouway/pos/print/core/CoreModule.kt +++ b/pos-print/src/main/java/com/clouway/pos/print/core/CoreModule.kt @@ -2,6 +2,7 @@ package com.clouway.pos.print.core import com.clouway.pos.print.printer.FP705PrinterFactory import com.google.inject.AbstractModule +import com.google.inject.Singleton /** * @author Martin Milev @@ -10,5 +11,7 @@ class CoreModule : AbstractModule() { override fun configure() { bind(PrinterFactory::class.java).to(FP705PrinterFactory::class.java) + bind(PrintQueue::class.java).to(InMemoryPrintQueue::class.java).`in`(Singleton::class.java) + bind(PrintingListener::class.java).to(ReceiptPrintingListener::class.java).`in`(Singleton::class.java) } } \ No newline at end of file diff --git a/pos-print/src/main/java/com/clouway/pos/print/core/InMemoryPrintQueue.kt b/pos-print/src/main/java/com/clouway/pos/print/core/InMemoryPrintQueue.kt new file mode 100644 index 0000000..d876335 --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/core/InMemoryPrintQueue.kt @@ -0,0 +1,21 @@ +package com.clouway.pos.print.core + +import java.util.concurrent.ArrayBlockingQueue + +/** + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +class InMemoryPrintQueue : PrintQueue { + + private val queueMax = 50 + + private val queue = ArrayBlockingQueue(queueMax) + + override fun next(): ReceiptRequest { + return queue.take() + } + + override fun queue(receiptRequest: ReceiptRequest) { + queue.offer(receiptRequest) + } +} \ No newline at end of file diff --git a/pos-print/src/main/java/com/clouway/pos/print/core/PrintQueue.kt b/pos-print/src/main/java/com/clouway/pos/print/core/PrintQueue.kt new file mode 100644 index 0000000..4053585 --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/core/PrintQueue.kt @@ -0,0 +1,23 @@ +package com.clouway.pos.print.core + +/** + * Provides the methods to iterate over and append to a + * queue of receipts. + * + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +interface PrintQueue { + + /** + * Returns the next receipt. + */ + fun next(): ReceiptRequest? + + /** + * Queues a receipt along with its source and + * fiscal state to the queue. + * + * @param receiptRequest the receipt to queue + */ + fun queue(receiptRequest: ReceiptRequest) +} \ No newline at end of file diff --git a/pos-print/src/main/java/com/clouway/pos/print/core/PrintingListener.kt b/pos-print/src/main/java/com/clouway/pos/print/core/PrintingListener.kt new file mode 100644 index 0000000..cabfd2f --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/core/PrintingListener.kt @@ -0,0 +1,17 @@ +package com.clouway.pos.print.core + +/** + * Provides the methods to give feedback after a print + * has been attempted. + * + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +interface PrintingListener { + /** + * Notifies when a printing status has been updated. + * + * @param receipt the updated receipt + * @param printStatus the new status + */ + fun onPrinted(receipt: Receipt, printStatus: PrintStatus) +} \ No newline at end of file diff --git a/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptPrintingListener.kt b/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptPrintingListener.kt new file mode 100644 index 0000000..54189e8 --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptPrintingListener.kt @@ -0,0 +1,16 @@ +package com.clouway.pos.print.core + +import org.slf4j.LoggerFactory + +/** + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +class ReceiptPrintingListener : PrintingListener { + override fun onPrinted(receipt: Receipt, printStatus: PrintStatus) { + val logger = LoggerFactory.getLogger(PrintingListener::class.java) + + logger.info("Receipt was processed") + logger.info(receipt.receiptId) + logger.info(printStatus.name) + } +} \ No newline at end of file diff --git a/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptRepository.kt b/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptRepository.kt new file mode 100644 index 0000000..81b972c --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptRepository.kt @@ -0,0 +1,105 @@ +package com.clouway.pos.print.core + +import java.util.Optional + +/** + * Provides the methods to persist, save and check the status + * of requested receipts. + * + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +interface ReceiptRepository { + + /** + * Registers a receipt request. + * + * @param receiptRequest the receipt to register + * @return the request id + */ + @Throws(ReceiptAlreadyRegisteredException::class) + fun register(receiptRequest: ReceiptRequest): String + + /** + * Returns a receipt request built from the stored document + * with a given requestId. + * + * @param requestId the id of the request to return. + * @return a ReceiptRequest + */ + fun getReceiptPrintRequest(requestId: String): ReceiptRequest + + /** + * Returns the printing status of a receipt. + * + * @param receiptRequestId the id of the receipt request + * @return the status of the receipt + */ + @Throws(ReceiptNotRegisteredException::class) + fun getStatus(receiptRequestId: String): PrintStatus + + /** + * Returns the latest receipts up to a limit. + * + * @param limit The amount of receipts to return. + * @return A map of receipts with their request ids as keys. + */ + fun getLatest(limit: Int): Map + + /** + * Retrieves an optional receipt by its request's id. + * + * @param receiptRequestId the receipt request's id + * @return an optional receipt + */ + fun getByRequestId(receiptRequestId: String): Optional + + /** + * Retrieves a list of receipts by source ip. + * + * @param sourceIp the ip to query by + * @return a list of receipts + */ + fun getBySourceIp(sourceIp: String): List + + /** + * Retrieves a list of receipts by operator id. + * + * @param operatorId the operator to query by + * @return a list of receipts + */ + fun getByOperatorId(operatorId: String): List + + /** + * Returns a list of exceptions with a given status. + * + * @param receiptStatus the status to group by + * @return the list of receipts with that status + */ + fun getByStatus(receiptStatus: PrintStatus): List + + /** + * Marks a receipt as Printed. + * + * @param requestId the id of the receipt to finish + * @return the finished receipt + */ + @Throws(ReceiptNotRegisteredException::class) + fun finishPrinting(requestId: String): Receipt + + /** + * Marks a receipt as Failed. + * + * @param requestId the id of the receipt to fail. + * @return the rejected receipt + */ + @Throws(ReceiptNotRegisteredException::class) + fun failPrinting(requestId: String): Receipt +} + + +internal class ReceiptNotRegisteredException : Throwable() +internal class ReceiptAlreadyRegisteredException : Throwable() + +enum class PrintStatus { + PRINTING, PRINTED, FAILED +} \ No newline at end of file diff --git a/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptRequest.kt b/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptRequest.kt new file mode 100644 index 0000000..60065a5 --- /dev/null +++ b/pos-print/src/main/java/com/clouway/pos/print/core/ReceiptRequest.kt @@ -0,0 +1,13 @@ +package com.clouway.pos.print.core + +/** + * An object containing a receipt and information on how to print it. + * A requestId can be appended when requeueing. + * + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +data class ReceiptRequest(val receipt: Receipt, + val sourceIp: String, + val operatorId: String, + val isFiscal: Boolean, + val requestId: String = "") \ No newline at end of file diff --git a/pos-print/src/test/java/com/clouway/pos/print/adapter/http/PrintServiceV2Test.kt b/pos-print/src/test/java/com/clouway/pos/print/adapter/http/PrintServiceV2Test.kt new file mode 100644 index 0000000..e4da262 --- /dev/null +++ b/pos-print/src/test/java/com/clouway/pos/print/adapter/http/PrintServiceV2Test.kt @@ -0,0 +1,142 @@ +package com.clouway.pos.print.adapter.http + +import com.clouway.pos.print.FakeRequest +import com.clouway.pos.print.ReplyMatchers +import com.clouway.pos.print.core.PrintQueue +import com.clouway.pos.print.core.PrintStatus +import com.clouway.pos.print.core.Receipt +import com.clouway.pos.print.core.ReceiptAlreadyRegisteredException +import com.clouway.pos.print.core.ReceiptNotRegisteredException +import com.clouway.pos.print.core.ReceiptRepository +import com.clouway.pos.print.core.ReceiptRequest +import com.google.gson.Gson +import org.hamcrest.MatcherAssert +import org.jmock.AbstractExpectations.returnValue +import org.jmock.AbstractExpectations.throwException +import org.jmock.Expectations +import org.jmock.Mockery +import org.jmock.integration.junit4.JUnitRuleMockery +import org.junit.Rule +import org.junit.Test + +/** + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +class PrintServiceV2Test { + + @Rule + @JvmField + var context = JUnitRuleMockery() + + private fun Mockery.expecting(block: Expectations.() -> Unit) { + checking(Expectations().apply(block)) + } + + private val repo = context.mock(ReceiptRepository::class.java) + private val queue = context.mock(PrintQueue::class.java) + + private val receipt = Receipt.newReceipt().withReceiptId("::receipt-id::").build() + + private val sourceIp = "::sourceIp::" + private val operatorId = "::operatorId::" + + private val fiscalReceiptDTO = PrintServiceV2 + .ReceiptDTO(sourceIp, operatorId, true, receipt = receipt) + private val nonFiscalReceiptDTO = PrintServiceV2 + .ReceiptDTO(sourceIp, operatorId, false, receipt = receipt) + + private val fiscalRequestId = "::fiscal-request-id::" + private val nonFiscalRequestId = "::non-fiscal-request-id::" + + private val fiscalReceiptRequest = ReceiptRequest(receipt, sourceIp, operatorId, true) + private val nonFiscalReceiptRequest = ReceiptRequest(receipt, sourceIp, operatorId, false) + + private val service = PrintServiceV2(repo, queue) + + @Test + fun registerReceiptsForPrinting() { + context.expecting { + oneOf(repo).register(fiscalReceiptRequest) + will(returnValue(fiscalRequestId)) + + oneOf(repo).register(nonFiscalReceiptRequest) + will(returnValue(nonFiscalRequestId)) + + oneOf(queue).queue(fiscalReceiptRequest.copy(requestId = fiscalRequestId)) + oneOf(queue).queue(nonFiscalReceiptRequest.copy(requestId = nonFiscalRequestId)) + } + + val fiscalReply = service.printReceipt(FakeRequest.newRequest(fiscalReceiptDTO)) + val nonFiscalReply = service.printReceipt(FakeRequest.newRequest(nonFiscalReceiptDTO)) + + MatcherAssert.assertThat(fiscalReply, ReplyMatchers.isAccepted) + MatcherAssert.assertThat(fiscalReply, ReplyMatchers.contains(fiscalRequestId)) + MatcherAssert.assertThat(nonFiscalReply, ReplyMatchers.isAccepted) + MatcherAssert.assertThat(nonFiscalReply, ReplyMatchers.contains(nonFiscalRequestId)) + } + + @Test + fun savingAlreadyExistingReceiptThrowsException() { + context.expecting { + oneOf(repo).register(fiscalReceiptRequest) + will(throwException(ReceiptAlreadyRegisteredException())) + } + + val reply = service.printReceipt(FakeRequest.newRequest(fiscalReceiptDTO)) + MatcherAssert.assertThat(reply, ReplyMatchers.isBadRequest) + } + + @Test + fun getReceiptStatus() { + context.expecting { + oneOf(repo).getStatus(receipt.receiptId) + will(returnValue(PrintStatus.PRINTING)) + } + + val reply = service.getReceiptStatus(receipt.receiptId) + MatcherAssert.assertThat(reply, ReplyMatchers.isOk) + MatcherAssert.assertThat(reply, ReplyMatchers.contains(PrintStatus.PRINTING)) + } + + @Test + fun getLatestReceipts() { + context.expecting { + oneOf(repo).getLatest(1) + will(returnValue(mapOf("::request-id::" to receipt))) + + oneOf(repo).getStatus("::request-id::") + will(returnValue(PrintStatus.FAILED)) + } + + val receiptWithStatus = PrintServiceV2.ReceiptWithStatusDTO("FAILED", "::request-id::", receipt) + + val reply = service.getLatestReceipts(1) + MatcherAssert.assertThat(reply, ReplyMatchers.isOk) + MatcherAssert.assertThat(reply, ReplyMatchers + .contains(listOf(receiptWithStatus))) + } + + @Test + fun requeueReceiptByRequestId(){ + context.expecting { + oneOf(repo).getReceiptPrintRequest("::request-id::") + will(returnValue(fiscalReceiptRequest)) + + oneOf(queue).queue(fiscalReceiptRequest) + } + + val reply = service.requeueReceipt("::request-id::") + MatcherAssert.assertThat(reply, ReplyMatchers.isOk) + } + + @Test + fun gettingReceiptStatusOfNonExistingReturnsNotFound() { + context.expecting { + oneOf(repo).getStatus(receipt.receiptId) + will(throwException(ReceiptNotRegisteredException())) + } + + val reply = service.getReceiptStatus(receipt.receiptId) + MatcherAssert.assertThat(reply, ReplyMatchers.isNotFound) + } +} \ No newline at end of file diff --git a/pos-print/src/test/java/com/clouway/pos/print/core/BackgroundReceiptPrintingServiceTest.kt b/pos-print/src/test/java/com/clouway/pos/print/core/BackgroundReceiptPrintingServiceTest.kt new file mode 100644 index 0000000..c51a22f --- /dev/null +++ b/pos-print/src/test/java/com/clouway/pos/print/core/BackgroundReceiptPrintingServiceTest.kt @@ -0,0 +1,152 @@ +package com.clouway.pos.print.core + +import com.clouway.pos.print.persistent.DatastoreCleaner +import com.clouway.pos.print.persistent.DatastoreRule +import com.clouway.pos.print.printer.Status +import org.jmock.AbstractExpectations.returnValue +import org.jmock.AbstractExpectations.throwException +import org.jmock.Expectations +import org.jmock.Mockery +import org.jmock.integration.junit4.JUnitRuleMockery +import org.jmock.lib.concurrent.Synchroniser +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +class BackgroundReceiptPrintingServiceTest { + + @Rule + @JvmField + var context = JUnitRuleMockery() + + init { + context.setThreadingPolicy(Synchroniser()) + } + + companion object { + @ClassRule + @JvmField + val dataStoreRule = DatastoreRule() + } + + @Rule + @JvmField + var cleaner = DatastoreCleaner(dataStoreRule.db()) + + private val repo = context.mock(ReceiptRepository::class.java) + private val factory = context.mock(PrinterFactory::class.java) + private val queue = context.mock(PrintQueue::class.java) + + private val service = BackgroundReceiptPrintingService(repo, factory, queue) + + private val printer = context.mock(ReceiptPrinter::class.java) + + private val receipt = Receipt.newReceipt() + .withReceiptId("::receiptId::") + .withAmount(200.0) + .build() + + private val requestId = "::requestId::" + private val sourceIp = "::sourceIp::" + private val operatorId = "::operatorId::" + private val isFiscal = true + + private val fiscalReceiptRequest = ReceiptRequest(receipt, sourceIp, operatorId, isFiscal, requestId) + + private val acceptedPrintingResponse = PrintReceiptResponse(setOf(Status.FISCAL_RECEIPT_IS_OPEN)) + private val rejectedPrintingResponse = PrintReceiptResponse(setOf(Status.BROKEN_PRINTIN_MECHANISM)) + + @Test + fun printReceipt() { + context.expecting { + oneOf(queue).next() + will(returnValue(fiscalReceiptRequest)) + + oneOf(factory).getPrinter(sourceIp) + will(returnValue(printer)) + + oneOf(printer).printFiscalReceipt(receipt) + will(returnValue(acceptedPrintingResponse)) + + oneOf(repo).finishPrinting(requestId) + will(returnValue(receipt)) + + oneOf(printer).close() + + oneOf(queue).next() + will(returnValue(null)) + } + + service.printReceipts() + } + + @Test + fun failPrintingReceiptWhenNotPrinted(){ + context.expecting { + oneOf(queue).next() + will(returnValue(fiscalReceiptRequest)) + + oneOf(factory).getPrinter(sourceIp) + will(returnValue(printer)) + + oneOf(printer).printFiscalReceipt(receipt) + will(returnValue(rejectedPrintingResponse)) + + oneOf(repo).failPrinting(requestId) + will(returnValue(receipt)) + + oneOf(printer).close() + + oneOf(queue).next() + will(returnValue(null)) + } + + service.printReceipts() + } + + @Test + fun failPrintingWhenDeviceNotFound(){ + context.expecting { + oneOf(queue).next() + will(returnValue(fiscalReceiptRequest)) + + oneOf(factory).getPrinter(sourceIp) + will(throwException(DeviceNotFoundException())) + + oneOf(repo).failPrinting(requestId) + will(returnValue(receipt)) + + oneOf(queue).next() + will(returnValue(null)) + } + + service.printReceipts() + } + + @Test + fun failPrintingWithIOException(){ + context.expecting { + oneOf(queue).next() + will(returnValue(fiscalReceiptRequest)) + + oneOf(factory).getPrinter(sourceIp) + will(throwException(IOException())) + + oneOf(repo).failPrinting(requestId) + will(returnValue(receipt)) + + oneOf(queue).next() + will(returnValue(null)) + } + + service.printReceipts() + } + + private fun Mockery.expecting(block: Expectations.() -> Unit) { + checking(Expectations().apply(block)) + } +} \ No newline at end of file diff --git a/pos-print/src/test/java/com/clouway/pos/print/persistent/PersistentReceiptRepositoryTest.kt b/pos-print/src/test/java/com/clouway/pos/print/persistent/PersistentReceiptRepositoryTest.kt new file mode 100644 index 0000000..61014ab --- /dev/null +++ b/pos-print/src/test/java/com/clouway/pos/print/persistent/PersistentReceiptRepositoryTest.kt @@ -0,0 +1,206 @@ +package com.clouway.pos.print.persistent + +import com.clouway.pos.print.adapter.db.PersistentReceiptRepository +import com.clouway.pos.print.core.PrintStatus +import com.clouway.pos.print.core.PrintingListener +import com.clouway.pos.print.core.Receipt +import com.clouway.pos.print.core.ReceiptAlreadyRegisteredException +import com.clouway.pos.print.core.ReceiptNotRegisteredException +import com.clouway.pos.print.core.ReceiptRequest +import com.google.inject.util.Providers +import org.jmock.Expectations +import org.jmock.Mockery +import org.jmock.integration.junit4.JUnitRuleMockery +import org.junit.Assert.assertThat +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test +import org.hamcrest.CoreMatchers.`is` as Is + +/** + * @author Tsvetozar Bonev (tsvetozar.bonev@clouway.com) + */ +class PersistentReceiptRepositoryTest { + companion object { + @ClassRule + @JvmField + val dataStoreRule = DatastoreRule() + } + + @Rule + @JvmField + val context: JUnitRuleMockery = JUnitRuleMockery() + + @Rule + @JvmField + var cleaner = DatastoreCleaner(dataStoreRule.db()) + + private val printingListener = context.mock(PrintingListener::class.java) + + private val repository = PersistentReceiptRepository(Providers.of(dataStoreRule.db()), printingListener) + + private val receipt = Receipt.newReceipt().withReceiptId("::receiptId::").build() + + private val sourceIp = "::sourceIp::" + private val operatorId = "::operatorId::" + + private val fiscalReceiptRequest = ReceiptRequest(receipt, sourceIp, operatorId, true) + private val nonFiscalReceiptRequest = ReceiptRequest(receipt, sourceIp, operatorId, false) + + @Test + fun happyPath() { + val fiscalRequestId = repository.register(fiscalReceiptRequest) + val nonFiscalRequestId = repository.register(nonFiscalReceiptRequest) + + assertThat(repository.getStatus(fiscalRequestId), Is(PrintStatus.PRINTING)) + assertThat(repository.getStatus(nonFiscalRequestId), Is(PrintStatus.PRINTING)) + } + + @Test(expected = ReceiptAlreadyRegisteredException::class) + fun registeringSameReceiptThrowsException() { + repository.register(fiscalReceiptRequest) + repository.register(fiscalReceiptRequest) + } + + @Test + fun getReceiptById() { + val requestId = repository.register(fiscalReceiptRequest) + + val retrievedReceipt = repository.getByRequestId(requestId) + + assertThat(retrievedReceipt.isPresent, Is(true)) + assertThat(retrievedReceipt.get(), Is(receipt)) + } + + @Test + fun getReceiptByIdReturnsEmptyWhenNotFound() { + val retrievedReceipt = repository.getByRequestId("::non-existent-receipt::") + + assertThat(retrievedReceipt.isPresent, Is(false)) + } + + @Test + fun getLastReceipts() { + val nonFiscalRequestId = repository.register(nonFiscalReceiptRequest) + val fiscalRequestId = repository.register(fiscalReceiptRequest) + + val retrievedNonFiscalReceipt = repository.getByRequestId(nonFiscalRequestId) + val retrievedFiscalFiscalReceipt = repository.getByRequestId(fiscalRequestId) + + val lastReceipts = repository.getLatest(2) + + assertThat(lastReceipts, Is(mapOf(nonFiscalRequestId to retrievedNonFiscalReceipt.get(), + fiscalRequestId to retrievedFiscalFiscalReceipt.get()))) + } + + @Test + fun getReceiptRequestForRequestId(){ + val requestId = repository.register(fiscalReceiptRequest) + + val retrievedFiscalReceiptPrintRequest = repository.getReceiptPrintRequest(requestId) + + assertThat(fiscalReceiptRequest.copy(requestId = requestId), Is(retrievedFiscalReceiptPrintRequest)) + } + + @Test(expected = ReceiptNotRegisteredException::class) + fun retrievingReceiptPrintRequestForNonExistentReceiptThrowsException(){ + repository.getReceiptPrintRequest("::request-id::") + } + + @Test + fun getReceiptStatus() { + val requestId = repository.register(fiscalReceiptRequest) + + assertThat(repository.getStatus(requestId), Is(PrintStatus.PRINTING)) + } + + @Test(expected = ReceiptNotRegisteredException::class) + fun gettingStatusOfNonExistingReceiptThrowsException() { + repository.getStatus("::non-existent-receiptId::") + } + + @Test + fun getReceiptsByStatus() { + repository.register(fiscalReceiptRequest) + + val printingReceipts = repository.getByStatus(PrintStatus.PRINTING) + val printedReceipts = repository.getByStatus(PrintStatus.PRINTED) + + assertThat(printingReceipts, Is(listOf(receipt))) + assertThat(printedReceipts, Is(emptyList())) + } + + @Test + fun getReceiptsBySourceIp() { + repository.register(fiscalReceiptRequest) + repository.register(nonFiscalReceiptRequest) + + val receipts = repository.getBySourceIp(sourceIp) + + assertThat(receipts, Is(listOf(receipt, receipt))) + } + + @Test + fun getBySourceIpReturnsEmptyList() { + val receipts = repository.getBySourceIp("::non-existent-source::") + + assertThat(receipts, Is(emptyList())) + } + + @Test + fun getReceiptsByOperatorId() { + repository.register(fiscalReceiptRequest) + repository.register(nonFiscalReceiptRequest) + + val receipts = repository.getByOperatorId(operatorId) + + assertThat(receipts, Is(listOf(receipt, receipt))) + } + + @Test + fun getByOperatorIdReturnsEmptyList() { + val receipts = repository.getByOperatorId("::non-existent-operator::") + + assertThat(receipts, Is(emptyList())) + } + + @Test + fun finishPrintingReceipt() { + context.expecting { + oneOf(printingListener).onPrinted(receipt, PrintStatus.PRINTED) + } + + val requestId = repository.register(fiscalReceiptRequest) + val finishedReceipt = repository.finishPrinting(requestId) + + assertThat(repository.getStatus(requestId), Is(PrintStatus.PRINTED)) + assertThat(finishedReceipt, Is(receipt)) + } + + @Test(expected = ReceiptNotRegisteredException::class) + fun finishingNonExistingReceiptThrowsException() { + repository.finishPrinting("::fake-receiptId::") + } + + @Test + fun rejectPrintingReceipt() { + context.expecting { + oneOf(printingListener).onPrinted(receipt, PrintStatus.FAILED) + } + + val requestId = repository.register(fiscalReceiptRequest) + val rejectedReceipt = repository.failPrinting(requestId) + + assertThat(repository.getStatus(requestId), Is(PrintStatus.FAILED)) + assertThat(rejectedReceipt, Is(receipt)) + } + + @Test(expected = ReceiptNotRegisteredException::class) + fun rejectingNonExistingReceiptThrowsException() { + repository.finishPrinting("::fake-receiptId::") + } + + private fun Mockery.expecting(block: Expectations.() -> Unit) { + checking(Expectations().apply(block)) + } +} \ No newline at end of file