Онлайн касса для JoomShopping и прочих CMS


image

Эта статья написана под влиянием небольшой паники в связи со стремительно приближающимся «днем Х» — очередной переход на онлайн кассы для очередной категории предприятий и организаций. Теперь с 1-го июля онлайн чеки должны выдавать даже те, кто их раньше мог не выдавать — интернет магазины и торговые автоматы.

Как же выдать чек?

Я довольно долго изучаю этот вопрос, так как мне нужно как-то автоматизировать процесс выдачи чеков для интернет магазина. Мне нужно решить эту техническую проблему с минимальными затратами по деньгам и по времени. С кассами ранее дела не имел и вот сейчас пытаюсь это дело изучить и понять и запустить в работу.

Здесь небольшой обзор возможных решений и мои криворукие скрипты на питоне.
Мое решение не коробочный продукт, но возможно у кого-то есть свой напильник и он сможет его довести до ума…

Это возможно подойдет тем, у кого

  1. редкие заказы в магазине
  2. небольшой ассортимент продаваемых штучных товаров

К сожалению, все это очень хрупкое и далеко не идеальное… увы.

В принципе, готовые решения есть. Наверняка я знаком не со всеми, но мне показалось, что большинство предложений по внедрению онлайн касс для интернет магазинов — это предложения с абонентской платой. Такие решения подразумевают либо размещение физической кассы в облаке, либо размещение кассы у заказчика, но интеграция с магазином происходит скажем через Яндекс.Кассу и далее облачного оператора (который берет абонентскую плату). Я вполне допускаю, что абонентская плата не является проблемой для интернет магазинов с большим оборотом. А вот если оборот значительно меньше миллиона рублей в год, то внедрение онлайн кассы с абон платой вполне может пошатнуть бизнес. Предлагаю не обсуждать вопрос «зачем вообще запускать такой интернет магазин у которого и оборота нет». Сегодня пока нет, а завтра возможно будет. К тому же интернет магазин может быть просто попутным бизнесом, который не столько продает товар, сколько рекламирует компанию.

Внедрение онлайн касс — это несомненно увеличение расходов магазина. Я не считаю первоначальные вложения, такие как приобретение собственно кассы, получение ключа для кабинета налоговой для регистрации кассы. Кстати, если кто-то скажет, что стоимость касс компенсируется налоговым вычетом — увы не всем, а только ИП.

Обязательные операционные расходы — это

  • фискальный накопитель, примерно 7 тысяч рублей в год
  • договор с ОФД, примерно 3 тысячи рублей в год
  • абонентская плата за онлайн кассу по подписке или в облаке примерно 1-3 тысячи в месяц

Конечно, тут числами можно немного «поиграть», купить фискальный накопитель не на год, а на три, и с ОФД так же… Нашел только один ОФД, который предлагает микротариф 999 рублей за год. На этом кажется действительно можно немного сэкономить. А вот абонентская плата за онлайн кассу, какая бы она не была, вот этого хотелось бы избежать…

Цены на облачный сервис онлайн касс примерно вот такие: kassa.yandex.ru/54fz.html
Там по ссылке все тарифы около 30 тысяч в год, только «Бизнес.ру Онлайн-Чеки» вроде бы за «смешные» 3600 рублей в год. Но переходишь по ссылке далее и там уже другие числа. Вот такие дела.

Из всех онлайн касс для интернет магазинов я для себя особо выделил вот эти:

1) касса «micropay on-line»

image

Но тут как-то нет технических подробностей, описание API есть, но какое-то жиденькое…
И стоит 15 тыр. А почему собственно касса без дисплея, клавиш и принтера стоит дороже, чем некоторые другие кассы с дисплеем, кнопками и принтером… Ценообразование не понятно.

2) ККТ РП-Система 1 ФС

image

Эта штука я так понял только в облаке может стоять, то есть от абонентской платы не уйти. И кажется эта штука не продается на руки (но это не точно — я нашел рекламу, где оно продается, но непосредственно производитель говорит, что нигде не купить).

3) Дримкас Пульс

image

На момент написания этой статьи только предзаказ.

Пока не известно, что за зверь, описания нет, цены нет.

Получается, что собственно решений для чистого интернет магазина как-то не очень и много.
Правда… ну есть некие вполне достойные промежуточные решения. Например, как я понял, кассовый аппарат Дримкас Ф вполне можно заставить работать для интернет магазина бесплатно без абонентской платы.

Но, с некоторыми условиями.

Если магазин на Wordpress или OpenCart или 1C или еще некоторые, то можно сделать следующее: в интернет магазин устанавливается готовый компонент/модуль от Дримкас. Этот модуль передает информацию о покупках в интернет Кабинет Дримкас. Сама касса Дримкас Ф стоит в офисе владельца интернет магазина и периодически (кажется раз в минуту) опрашивает Кабинет Дримкас и смотрит может нужно напечатать чек и при необходимости печатает его. Работа с Кабинетом Дримкас и его API бесплатна.

К сожалению, у меня магазин построен на JoomShopping. Готового модуля интеграции нет.
Могу ли я его сам написать? Теоретически да, могу. Правда я не очень умею в PHP, но не это главное… Да, Дримкас дает описание API своего кабинета. Есть модуль Дримкаса скажем для Wordpress — его исходники легко посмотреть и хотя бы понять что там и как там.

В принципе, я с этого и начал. Взял исходники компонента для Wordpress и стал в них ковыряться. Цель была сделать запрос к Кабинету Дримкас, и чтобы он мне уверенно сказал, чек готов к печати, но сперва подключите кассу. Если кто хочет посмотреть код, он вот:

PHP скрипт, который пытается отправить чек Кабинету Дримкас
<?php
<?php
/*
Plugin Name: Дримкас
Description: Позволяет фискализировать заказы магазина через обычную кассу от Дримкас (Дримкас-Ф).
Plugin URI: http://wordpress.org/plugins/dreamkas/
Author: Alt-Team
Version: 1.0.0
Author URI: http://alt-team.ru/
*/

//use WC_Payment_Gateways;

function get_option( $name )
{
    if( $name=='dreamkas_access_token' )
	return '53b32765-XXXX-XXXX-XXXX-93737750bdfc';
    if( $name=='dreamkas_payments_ids' )
	return 'Yandex.Kassa';
    if( $name=='dreamkas_tax_mode')
	return 'SIMPLE';
    if( $name=='dreamkas_tax_type' )
	return 'NDS_NO_TAX';
    if( $name=='dreamkas_device_id' )
	return '29XXXX';
}

final class xorder {
        public function get_status()
	{
	    return 'Payed';
	}
        public function get_payment_method()
	{
	    return 'Yandex.Kassa';
	}
	
	public function get_items()
	{
	    $product1 = array(
		"product_id" => 123,
		"name" => "Book",
		"price" => 500,
		"quantity" => 2,
		"total" => 1000,
		"total_tax" => 0
	    );
	    $product2 = array(
		"product_id" => 124,
		"name" => "Toy",
		"price" => 150,
		"quantity" => 1,
		"total" => 1000,
		"total_tax" => 0
	    );
	    $items = [$product1,$product2];
	    return $items;
	}
	public function get_billing_email()
	{
	    return "nck.kovach@gmail.com";
	}
	public function get_billing_phone()
	{
	    return "";
	}
        public function get_total()
	{
	    return 1150;
	}
}

function wc_get_order($order_id)
{
    return new xorder();
}

final class Dreamkas {

    public $version = '1.0.0';

    const DEFAULT_QUEUE_NAME = 'default';
    const DISCOUNT_NOT_AVAILABLE = 0;

    private static $_instance = null;

    public static function instance() {
        if (is_null(self::$_instance) ) {
            self::$_instance = new self();
        }
        return self::$_instance;
    }

    public function __construct()
    {
        //$this->define('DREAMKAS_ABSPATH', plugin_dir_path( __FILE__));
        //$this->define('DREAMKAS_ABSPATH_VIEWS', plugin_dir_path( __FILE__) . 'includes/views/');
        //$this->define('DREAMKAS_BASENAME', plugin_basename( __FILE__ ));

        $this->define('DREAMKAS_ABSPATH', ".");
        $this->define('DREAMKAS_ABSPATH_VIEWS', "." . 'includes/views/');
        $this->define('DREAMKAS_BASENAME', ".");

        $this->includes();
        $this->hooks();
        $this->wp_hooks();
        $this->wp_endpoints();
        $this->load_options();
        $this->init();
    }

    public function wp_hooks()
    {
        //register_activation_hook( __FILE__, array('Dreamkas_Install', 'activation'));
        //add_action('woocommerce_order_status_' . get_option('dreamkas_fiscalize_on_order_status'), array($this, 'fiscalize'));
    }

    public function wp_endpoints()
    {
        //add_filter('query_vars', array($this, 'add_query_vars'), 0);
        //add_action('init', array($this, 'add_endpoint'), 0);
        //add_action('parse_request', array($this, 'handle_requests'), 0);
    }

    public function hooks()
    {
        //add_action('dreamkas_action_success', array($this, 'action_success'));
        //add_action('dreamkas_action_fail', array($this, 'action_fail'));
        //add_action('dreamkas_report_create', array($this, 'report_create'), 10, 4);
        //add_action('dreamkas_report_update', array($this, 'report_update'), 10, 3);
    }

    public function includes()
    {
/*
        require_once(DREAMKAS_ABSPATH . 'includes/class-dreamkas-install.php');
        //require_once('debug.php');
        
        if (is_admin()) {
            require_once(DREAMKAS_ABSPATH . 'includes/class-dreamkas-admin.php');
            add_action('init', array( 'Dreamkas_Admin', 'init'));
        }
*/
    }

    private function define($name, $value)
    {
        if (!defined( $name )) {
            define( $name, $value );
        }
    }

    public function load_options() {
        $this->access_token = get_option('dreamkas_access_token');
    }

    public function init()
    {
        //do_action('before_dreamkas_init');
        //do_action('dreamkas_init');
    }

    public function taxSystems() {
		return array(
			'DEFAULT' => 'Общая',
			'SIMPLE' => 'Упрощенная доход',
			'SIMPLE_WO' => 'Упрощенная доход минус расход',
			'ENVD' => 'Единый налог на вмененный доход',
			'AGRICULT' => 'Единыи? сельскохозяи?ственныи? налог',
			'PATENT' => 'Патентная система налогообложения'
		);
	}
        
    public function taxTypes() {
		return array(
			'0' => 'Выберите НДС',
			'NDS_NO_TAX' => 'Без НДС',
			'NDS_0' => 'НДС 0',
			'NDS_10' => 'НДС 10',
			'NDS_18' => 'НДС 18',
			'NDS_10_CALCULATED' => 'НДС 10/110',
			'NDS_18_CALCULATED' => 'НДС 18/118'
		);
	}
    public function paymentIds() {
/*
        $payments = WC_Payment_Gateways::instance();
        $paymentIds = $payments->get_payment_gateway_ids();//WC_Payment_Gateways::get_available_payment_gateways();
        
        foreach ($paymentIds as $key => $code) {
            $_paymentIds[$code] = $payments->payment_gateways[$key]->title;
        }
        return $_paymentIds;
*/
	}

    public function fiscalize($order_id)
    {
	echo "Fiscalize was called!\n";
        $order = wc_get_order($order_id);

        if (!$order) {
            return;
        }
	echo "Order exists\n";

        $status = $order->get_status();
        $payment = $order->get_payment_method();
        $payments_ids = get_option('dreamkas_payments_ids');

        if(!in_array($payment, explode(',', $payments_ids))) {
            return;
        }

        $tax_mode = get_option('dreamkas_tax_mode');
        $tax_type = get_option('dreamkas_tax_type');

        $items = array();
        if (sizeof($order->get_items()) > 0 ) {
            foreach ($order->get_items('line_item') as $product) {
                $product_tax_type = ""; //get_post_meta( $product['product_id'], 'dk_tax_type', true );
                $price = intval(($product['total']+$product['total_tax'])/$product['quantity']*100);
                if($price>0) {
                    $items[] = array(
                        "name"=> $product['name'], //->get_name(),
                        "type"=> "COUNTABLE",
                        "quantity"=> $product['quantity'],
                            "price"=> $price,
                        "priceSum"=> ($product['total']+$product['total_tax'])*100,
                        "tax"=>  empty($product_tax_type)?$tax_type:$product_tax_type,//"$tax_type",
                        "taxSum"=> 0//$product['total_tax']*100
                    );
                }
            }
        }
        // shipping
        foreach ($order->get_items('shipping') as $item) {
            $price = round(($item['total']+$item['total_tax'])*100);
            if($price>0) {
                $items[] = array(
                    "name"=> 'Доставка',//$item->get_name(),
                    "type"=> "COUNTABLE",
                    "quantity"=> 1,
                    "price"=> $price,
                    "priceSum"=> round(($item['total']+$item['total_tax'])*100),
                    "tax"=> "$tax_type",
                    "taxSum"=> 0//round($item['total_tax']*100)
                );
            }
        }
        //fn_write_die($items);
        if(!empty($items)) {
        $request = array(
            "deviceId" => get_option('dreamkas_device_id'),
            "type" => "SALE",
            "timeout" => 180,
            "taxMode" => get_option('dreamkas_tax_mode'),
            "positions" => $items,
            "payments" => array(
                array(
                    "sum" => $order->get_total()*100,
                    "type" => "CASHLESS"
                )
            ),
            "attributes" => array(
              "email" => $order->get_billing_email(),
              "phone" => $order->get_billing_phone(),
            ),
            "total" => array(
              "priceSum" => $order->get_total()*100
            )
        );

       //fnd($request,$order->get_items('line_item'),  $order);
        $ch = curl_init();

        $access_token = get_option('dreamkas_access_token');

        curl_setopt($ch, CURLOPT_HTTPHEADER, array(
            "Content-Type: application/json",
            "Authorization: Bearer $access_token"
        ));

        curl_setopt($ch, CURLOPT_URL, "https://kabinet.dreamkas.ru/api/receipts");
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
        curl_setopt($ch, CURLOPT_HEADER, FALSE);
        curl_setopt($ch, CURLOPT_POST, TRUE);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request));
        $response = curl_exec($ch);
        curl_close($ch);
        }

        if(!empty($response)) {
            $response = json_decode($response, true);
	var_dump( $response );
	return;
            /*$response = json_decode('{
                "id": "5956889136fdd7733f19cfe6",
                "createdAt": "2017-06-20 12:01:47.990Z",
                "status": "PENDING"
              }', true);*/

            global $wpdb;
            $table_name = $wpdb->prefix . 'dreamkas';
            $exist_order_id = $wpdb->get_row( $wpdb->prepare( "SELECT order_id FROM {$table_name} WHERE `order_id` = %d LIMIT 1;", $order_id ) );
            
            if((substr($response['status'], 0, 1)==4)) {
                $dk_date = time();
                if(empty($exist_order_id)) {
                    //$wpdb->query( "INSERT INTO $table_name ( `order_id` , `dk_id` , `dk_date`, `dk_status` ) VALUES ( '{$wpdb->blogid}', '{$wp_db_version}', NOW());" );
                    $wpdb->query("INSERT INTO `" . $wpdb->prefix . "dreamkas` SET "
                            . "`order_id` = '" . (int)$order_id . "', "
                            //. "`dk_id` = '".$response['id']."', "
                            . "`dk_date` ='".$dk_date."', "
                            . "`dk_status` = '" . $response['status']. "', "
                            . "`dk_message` = '" .$response['code'].':'.$response['message']. "' "
                            . "");
                } else {
                    $wpdb->query("UPDATE `" . $wpdb->prefix . "dreamkas` SET "
                        //. "`order_id` = '" . (int)$order_id . "', "
                        //. "`dk_id` = '".$response['id']."', "
                        . "`dk_date` ='".$dk_date."', "
                        . "`dk_status` = '" .$response['status']. "', "
                        . "`dk_message` = '" .$response['code'].':'.$response['message']. "' "
                        . " WHERE order_id = '" . (int)$order_id. "'");
                }
                //$this->log->write('Dreamkas debug: ' . json_encode($response));
                //fnd($exist_order_id, $response, $response['status']);
            } else {
                $dk_date = empty($response['createdAt'])?$response['completedAt']:$response['createdAt'];
                $message = empty($response['message'])?'':$response['code'].':'.$response['message'];
                if(empty($exist_order_id)) {
                    //$wpdb->query( "INSERT INTO $table_name ( `order_id` , `dk_id` , `dk_date`, `dk_status` ) VALUES ( '{$wpdb->blogid}', '{$wp_db_version}', NOW());" );
                    $wpdb->query("INSERT INTO `" . $wpdb->prefix . "dreamkas` SET `order_id` = '" . (int)$order_id . "', `dk_id` = '".$response['id']."', `dk_date` ='".$dk_date."', `dk_status` = '" . $response['status']. "', ". "`dk_message` = '" .$message. "' ");
                } else {
                    $wpdb->query("UPDATE `" . $wpdb->prefix . "dreamkas` SET `order_id` = '" . (int)$order_id . "', `dk_id` = '".$response['id']."', `dk_date` ='".$dk_date."', `dk_status` = '" .$response['status']. "', `dk_message` = '" .$message. "' WHERE order_id = '" . (int)$order_id. "'");
                }
            }
            //fnd($request, $response);
        }
    }

    public function add_query_vars($vars) {
        $vars[] = 'dreamkas';
        return $vars;
    }

    public static function add_endpoint() {
		add_rewrite_endpoint('dreamkas', EP_ALL);
    }

    public function handle_requests() {
        global $wp;

        if (empty($wp->query_vars['dreamkas'])) {
             return;
        }

        $dreamkas_action = strtolower(wc_clean( $wp->query_vars['dreamkas']));
        do_action('dreamkas_action_' . $dreamkas_action);
        die(-1);
    }

    public function action_success()
    {
        $this->handle_action('success');
    }

    public function action_fail()
    {
        //$this->handle_action('fail');
    }

    public function handle_action($action) {
        //do_action('dreamkas_report_update', intval($data['external_id']), $data['state'], $data);
    }
/*
    public function report_create($order_id, $request_check_data, $response_data, $error="")
    {
        $this->report->create($order_id, $request_check_data, $response_data, $error);
    }
*/
    /*
    public function report_update($order_id, $state, $report_data)
    {
        $this->report->update($order_id, $state, $report_data);
    }
     * 
     */
}

$inst = Dreamkas::instance();
$inst->fiscalize(1);


Это PHP скрипт из компонета для Wordpress. Я там закомментировал все запросы к базам данных и иммитирую передачу ордера в функцию fiscalize. Вызывать скрипт можно просто из консоли.терминала.

Я почему-то думал, что я смогу в Кабинете Дримкас увидеть что-то вроде очереди моих тестовых чеков, которые напечатаются, когда моя физическая касса выйдет онлайн (у меня же ведь могут быть и перебои с питанием и какой нибудь экскаватор теоретически может оборвать мой кабель к интернет провайдеру). Саму кассу еще пока не купил, так как не уверен в выборе.

Этот код на PHP выше в принципе как-то начинает работать. Там нужно в начале прописать access_token который берется из Кабинета Дримкас и еще device_id — это я так понял ID физической кассы. Таковой у меня пока нет. Я установил бесплатную кассовую программу «Дримкас Старт», зарегистрировал ее в кабинете Дримкас и таким образом получил ID кассы.

При запуске PHP скрипта я получаю ответ от Кабинета Дримкас:



Вот и думай, что с этим дальше делать. Видимо подключить «Дримкас Старт» таким способом нельзя? Только Дримкас Ф подходит? Вразумительного ответа пока не нашел.

И вообще, мне не очень понятен сам ход разработки. Чтобы API работало должен быть подключен боевой кассовый аппарат с реальным фискальным накопителем. Сколько чеков для бухгалтерии я испорчу, но отправлю в ОФД пока проведу свою разработку? В тех поддержке Дримкас мне сказали, что для разработки я должен использовать эмулятор фискального накопителя МГМ, который сам стоит как кассовый аппарат. Мне это не нравится.

Я бы честно говоря обрадовался, если бы Дримкас сделал не готовый компонент к какой-то CMS, а просто скрипт «функцию» fiscalize() проверенную и рекомендованную к использованию. А так я что-то как-то исправляю и если не работает я не понимаю, толи я наисправлял так и испортил, толи у них на стороне сервера что-то случилось. У меня же нет ни логов сервера, ничего — черный ящик.

Поскольку я уже в своих опытах начал использовать бесплатную кассовую программу «Дримкас Старт», то подумал, что вообще-то можно не делать автоматизацию вообще… Теоретически я могу в офисе, на сервере виртуальных машин создать еще одну виртуалку специально под кассу. В виртуалку нужно пробросить USB от кассового аппарата Вики Принт (с ними работает «Дримкас Старт»). В виртуалке запустить эту кассу. При поступлении заказа (это у меня не часто пока) мне приходит уведомление по почте. Я могу, где бы я ни был, подключиться удаленно к виртуалке и напечать чек вручную. Да плохо и не удобно, но что делать?

А потом я подумал… Постойте. А можно ли автоматизировать клики в кассовой программе?

Идея такая. На почту из интернет магазина приходит уведомление о новом заказе. Выглядит почтовое уведомление из магазина JoomShopping вот так:



Я пишу скрипт на питоне, который будет периодически опрашивать почтовый сервер и смотреть пришли письма или нет. Письма нужно отсортировать и найти действительно заказ с нужным статусом «Оплачено» (пока это поле не проверяю):

Скрип на питоне, который периодически проверяет почту и читает заказы из интернет магазина
#!/usr/bin/env python
import sys
import imaplib
import getpass
import email
import email.header
import datetime
import time
import base64
import codecs
import os

from order import *
from kassa import *

#print("Hello Kassa!")
#time.sleep(15)
#print("Lets start!")
#file = codecs.open("order.txt", "r", "utf-8")
#test_order_txt=file.read()
#order=parse_order_from_email(test_order_txt)
#print_order(order)
#make_check(order)

EMAIL_POLL_INTERVAL = 30
EMAIL_FROM_ACCEPTED = "info@supershop.ru"
EMAIL_ACCOUNT = "supershop.ru@gmail.com"
EMAIL_P = "r23fsdf^&G%(HOI"

# Use 'INBOX' to read inbox.  Note that whatever folder is specified, 
# after successfully running this script all emails in that folder 
# will be marked as read.
EMAIL_FOLDER = "INBOX"

def process_mailbox(M):
	"""
	Do something with emails messages in the folder.  
	For the sake of this example, print some headers.
	"""

	rv, data = M.search(None, "UNSEEN")
	if rv != 'OK':
		print("No messages found!")
		return

	for num in data[0].split():
		rv, data = M.fetch(num, '(RFC822)')
		if rv != 'OK':
			print("ERROR getting message", num)
			return

		msg = email.message_from_bytes(data[0][1])
		#print(msg)
		print("-------------------------")
		hdr = email.header.make_header(email.header.decode_header(msg['Subject']))
		subject = str(hdr)
		print('Message %s: %s' % (num, subject))
		print('Raw Date:', msg['Date'])
		# Now convert to local date-time
		date_tuple = email.utils.parsedate_tz(msg['Date'])
		if date_tuple:
			local_date = datetime.datetime.fromtimestamp(
				email.utils.mktime_tz(date_tuple))
			print ("Local Date:", 				local_date.strftime("%a, %d %b %Y %H:%M:%S"))
		print("From: ",msg['From'])
		if EMAIL_FROM_ACCEPTED not in msg['From']: 
			continue
		if msg.is_multipart():
			part = msg.get_payload(0)
			payload=part.get_payload(decode=True).decode('utf-8')
		else:
			payload = msg.get_payload(decode=True)
		#print("Text: ",payload)
		#break
		order=parse_order_from_email(payload)
		print_order(order)
		make_check(order)
		

M = imaplib.IMAP4_SSL('imap.gmail.com')

try:
	rv, data = M.login(EMAIL_ACCOUNT, EMAIL_P)
except imaplib.IMAP4.error:
	print ("LOGIN FAILED!!! ")
	sys.exit(1)

print(rv, data)

rv, mailboxes = M.list()
num_email_reads=0
if rv == 'OK':
	#print("Mailboxes:")
	#print(mailboxes)
	while 1:
		rv, data = M.select(EMAIL_FOLDER)
		if rv == 'OK':
			#update console title to see that requests to email go periodically
			os.popen("title " + "Emails2Kassa "+str(num_email_reads))
			num_email_reads=num_email_reads+1
			#print("Processing mailbox...\n")
			process_mailbox(M)
		time.sleep(EMAIL_POLL_INTERVAL)
	M.close()
else:
	print("ERROR: Unable to open mailbox ", rv)

M.logout()


Скрипт написан с использованием метода Google-Stackoverflow-Copy-Paste программирования.

Как только письмо с заказом получено, нужно произвести его разбор и извлечь нужные поля из него. Хорошо, что заказ приходит в HTML. Я исправил файл шаблона магазина JoomShopping components/com_jshopping/templates/my_def_div/checkout/orderemail.php и добавил важным полям дополнительный аттрибут data-order="?????":



По этому атрибуту другой питоновский скрипт может разобрать ордер и извлечь из HTML файла все, что нужно. Осторожно! Там регэкспы. Слабонервным не смотреть. Я сам боюсь туда заглядывать.

Разбор письма с ордером из магазина
#!/usr/bin/env python
import sys
import re
import codecs

#file = codecs.open("order.txt", "r", "utf-8")
#order_txt=file.read()

def parse_order_from_email(order_txt):
	order={}
	order_number_raw = re.search(' data-order=\"number\">\s*\d+\s*<\/td>', order_txt).group(0)
	order_number = re.search('\d+', order_number_raw).group(0)
	order["number"]=order_number
	#print (order_number)

	order_data_raw = re.search(' data-order=\"data\">\s*\d+\.\d+\.\d+\s*<\/td>', order_txt).group(0)
	order_data = re.search('\d+\.\d+\.\d+', order_data_raw).group(0)
	#print (order_data)
	#print ("Invoice ", order_number, " from ", order_data)
	order["data"]=order_data
	
	order_status_raw = re.search(' data-order="status">\D*<\/td>', order_txt).group(0)
	order_status = re.search('[\u0400-\u04FF]+\s*[\u0400-\u04FF]*', order_status_raw).group(0)
	#print (order_status)
	order["status"]=order_status

	order_email_raw = re.search(' data-order="email">[^@]+@[^<]*<\/td>', order_txt).group(0)
	order_email = re.search('>[^@]+@[^<]*', order_email_raw).group(0)
	order_email=order_email[1:]
	#print (order_email)
	order["email"]=order_email
	order["items"]=[]
	while 1:
		item={}
		#where product name starts?
		search_str=' data-order="name">'
		order_name_ = re.search(search_str, order_txt)
		if order_name_==None:
			break
		order_name_pos = order_name_.start()+len(search_str)
		order_txt=order_txt[order_name_pos:]
		#skip product <img ..> tag
		search_str='>'
		order_name_pos = re.search(search_str, order_txt).start()+len(search_str)
		order_txt=order_txt[order_name_pos:]
		#where <div> attribute starts
		search_str='<div'
		div_pos = re.search(search_str, order_txt).start()
		name=order_txt[:div_pos]
		name=name.strip()
		#print (name)
		item["name"]=name
		order_txt=order_txt[div_pos:]
		#where <div> attribute ends
		search_str='>'
		div_pos = re.search(search_str, order_txt).start()+len(search_str)
		order_txt=order_txt[div_pos:]
		#where attribute ends
		search_str='<'
		attr_pos = re.search(search_str, order_txt).start()
		attr=order_txt[:attr_pos]
		attr=attr.strip()
		order_txt=order_txt[div_pos:]
		item["attr"]=""
		if len(attr):
			#print (attr)
			item["attr"]=attr

		order_code_raw_ = re.search('data-order=\"code\">\d*<\/td>', order_txt)
		item["code"]=0
		if order_code_raw_ :
			order_code_raw = order_code_raw_.group(0)
			order_code_ = re.search('>\d+',order_code_raw)
			if order_code_ :
				order_code = order_code_.group(0)
				order_code = order_code[1:]
				#print ("Code ",order_code)
				item["code"]=order_code
				
		order_quantity_raw = re.search('data-order=\"quantity\">\d+<\/td>', order_txt).group(0)
		order_quantity = re.search('\d+', order_quantity_raw).group(0)
		#print (order_quantity)
		item["quantity"]=order_quantity

		order_singleprice_raw = re.search(' data-order="singleprice">\s*\d+.', order_txt).group(0)
		order_singleprice = re.search('\d+', order_singleprice_raw).group(0)
		#print (order_singleprice)
		item["singleprice"]=order_singleprice

		order_totalprice_raw = re.search(' data-order="totalprice">\s*\d+.', order_txt).group(0)
		order_totalprice = re.search('\d+', order_totalprice_raw).group(0)
		#print (order_totalprice)
		item["totalprice"]=order_totalprice
		#print ("---------------------")
		order["items"].append(item)
	return order
	
def print_order(order):
	#print(order)
	print ("---------------------")
	print( "Invoice ", order["number"], " from ", order["data"] )
	print( "Status ", order["status"] )
	print( "Status ", order["email"] )
	items_list=order["items"]
	for item in items_list :
		print( item["code"], ":", item["name"], ":", item["attr"],":",item["singleprice"],":",item["quantity"],":",item["totalprice"] )


Тут нужно заметить, что каждый товар должен иметь «код товара» и передавать его в ордере.

Тогда, после функции parse_order_from_email() получаю результат вот такую структуру (или мап? не знаю как это в питоне называется):

{
"number" => "00010023",
"data" => "11.03.2018",
"status" => "Оплачено",
"email" => "pupkin1994@mail.ru",
"items" => 
 {
  "code" => "123",
  "name" => "Нужная штука",
  "attr" => "",
  "singleprice" => "500",
  "quantity" => "3",
  "totalprice" => "1500"
 },
 {
  "code" => "125",
  "name" => "Ненужная штука",
  "attr" => "",
  "singleprice" => "5000",
  "quantity" => "1",
  "totalprice" => "5000"
 }
}

А теперь самое сложное.

Нужно «выполнить этот чек на кассе».
У меня в виртуальной машине вместе с работающими скриптами на питоне работает еще и кассовая программа «Дримкас Старт». Программа автоматизации должна кликать по нужным местам программы так, как написано в чеке.

Для этого попробую использовать pyautogui.

Нужно наделать скриншотов программы «Дримкас Старт» там где находятся кнопки GUI и питоновский pyautogui сможет найти эти изображения на экране с помощью функции pyautogui.locateOnScreen(img). Где нашел изображение кнопки, туда можно подвинуть мышь и сделать клик: pyautogui.moveTo( x, y, 0.5), pyautogui.click().

Скрипт автоматических кликов по программе Дримкас Старт
#!/usr/bin/env python
import pyautogui
import time
pyautogui.PAUSE = 3.0
mscale=1.0

def kassa_button_click( img ):
	pos = pyautogui.locateOnScreen(img)
	if pos!=None :
		x=int(pos[0]*mscale)
		y=int(pos[1]*mscale)
		print("Button ",img," at x=",x," y=",y)
		pyautogui.moveTo( x, y, 0.5)
		pyautogui.click()
		return
	print("Button ",img," not found")
	
def make_check(order):
	kassa_button_click('add_buyer.png');
	pyautogui.typewrite(order["email"]+"\n", interval=0.1)
	items_list=order["items"]
	for item in items_list :
		code=item["code"]
		quantity=int(item["quantity"])
		if quantity:
			if code:
				product_img_name="img\\"+str(code)+".png"
				pos = pyautogui.locateOnScreen(product_img_name)
				x=int(pos[0]*mscale)
				y=int(pos[1]*mscale)
				print("Product ",product_img_name," at x=",x," y=",y)
				pyautogui.moveTo( x, y, 0.5)
				if pos!=None :
					i=0
					while i<quantity:
						i=i+1
						pyautogui.click()
	kassa_button_click('raschot.png');
	kassa_button_click('card.png');
	kassa_button_click('gotovo_yellow.png');
	kassa_button_click('gotovo_green.png');


При этом (важно!), нужны изображения товарных позиций так же сделать скришотами и сохранить в папку img\ по имени «код товара.png».

Честно говоря вот это место очень ненадежное. PyAutoGUI — это pixel accurate сравнение эталонных картинок с экраном. Во-первых, в виндовсе может стоять scale экрана. Во-вторых, в настройках виндовс может случайно измениться настройка «font smooth». В третьих, если вдруг программа «Дримкас Старт» самообновится, то она может изменить внешний вид программы. Да и сама виндовс может обновиться — это думаю как-то нужно выключить. После любого из этих действий все мои скрипты перестанут работать и все развалится.



Я был бы счастлив, если бы в программе «Дримкас Старт» были бы какие-то keyboard short-cut keys. Все было бы гораздо надежней.

Ну можно еще подключить opencv — смотрел эти примеры, должно работать, даже если изображения не точные.

Итого, вот видео, которое показывает, как система может работать:


Здесь слева браузер в котором клиент оформляет заказ в интернет магазине. Справа виртуальная машина с запущенным скриптом Python, который читает почту, анализирует письма с заказами и по заказу кликает в интерфейсе «Дримкас Старт» программы. Там еще и е-мэйл клиента вбивается.

Все довольно примитивно, но в общем работает.

Конечно, нужно предусмотреть еще массу вещей вроде fail-safe-recovery. Например, упадет питоновский скрипт или программа дримкас, нужно перезапустить их. Еще проблема — касса должна открывать и закрывать смену. Это тоже можно сделать кликами, но я пока не сделал. Дальше хотелось бы добавить оповещение администратора о напечатанном чеке или о какой-то неполадке по почте.

Ну вот как-то так. Далеко не уверен, что такие методы будут позитивно встречены читателями. Но был бы признателен за любые комментарии. Так что критикуйте пожалуйста.

P.S.: Еще один момент во всей этой истории мне очень не по душе. Это — Кабинеты.
Все решения, которые я видел требуют дополнительных интернет кабинетов.
У меня должен появиться Кабинет Налоговой. Кабинет Яндекс.Кассы (ну он и так уже есть, ладно), Кабинет интегратора, например, Кабинет Дримкас. Ну и наконец ОФД Кабинет.
Здесь в этих кабинетах так много зла скрыто.

Как мне кажется, информация о продажах — это вообще-то коммерческая тайна.
Обидно, что Кабинет Дримкас (да вообще-то любое облачное решение какое найдете требует кабинета в котором будет все храниться и отображаться) получает забесплатно коммерческую тайну и главное нигде не обещают хранить эту тайну. То есть могут анализировать, могут продавать.

То что ОФД накапливает огромные базы — это тоже зло, но зло определенное по закону.
Вот такая вот действительность современной цивилизации.




К сожалению, не доступен сервер mySQL