python

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

  • четверг, 7 июня 2018 г. в 00:16:59
https://habr.com/post/413385/
  • Разработка под e-commerce
  • Платежные системы
  • Python
  • Joomla
  • CMS


image

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

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

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

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

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

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

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

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

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

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

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

1) касса «micropay on-line» www.micropay-fas.ru
image
Но тут как-то нет технических подробностей, описание API есть, но какое-то жиденькое…
И стоит 15 тыр. А почему собственно касса без дисплея, клавиш и принтера стоит дороже, чем некоторые другие кассы с дисплеем, кнопками и принтером… Ценообразование не понятно.

2) ККТ РП-Система 1 ФС online-kassa.pro/oborudovanie/proizvoditeli/kkt-starrus.html
image
Эта штука я так понял только в облаке может стоять, то есть от абонентской платы не уйти. И кажется эта штука не продается на руки (но это не точно — я нашел рекламу, где оно продается, но непосредственно производитель говорит, что нигде не купить).

3) Дримкас Пульс
dreamkas.ru/54fz/online-kassy/dreamkas-pulse
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. Например, упадет питоновский скрипт или программа дримкас, нужно перезапустить их. Еще проблема — касса должна открывать и закрывать смену. Это тоже можно сделать кликами, но я пока не сделал. Дальше хотелось бы добавить оповещение администратора о напечатанном чеке или о какой-то неполадке по почте.

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

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

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