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

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

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

Комментарии (16)


  1. SDKiller
    06.06.2018 21:24

    И зачем парсить письма от магазина, если эти же заказы сохраняются в БД вашего магазина?


    1. nckma Автор
      06.06.2018 23:10

      Чтобы не делать изменения в коде магазина.
      Чтобы не трогать то, что работает.


      1. SDKiller
        07.06.2018 06:15

        А их и не не нужно делать.
        Можно сделать плагином, если уж речь идет о Joomla, тем более в JoomShopping есть события, на что-то наподобие onAfterCreateOrder можете повесить обработку.
        Или, если вы предпочитаете писать standalone скрипты — настраиваете своему скрипту подключение к БД, забираете из нее заказы.


      1. peresada
        07.06.2018 07:58

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


        1. nckma Автор
          07.06.2018 08:38

          К сожалению нет другого разработчика и пока не предвидется…
          Я сам себе и жнец и на дуде игрец: и продукт делаю и сайт устанавливаю и магазин пытаюсь поднять. Пытался найти разработчиков на конкретные проблемы вроде перехода с Joomla 2.5 на 3.5 — было два и оба не сделали, хотя рассказывали красивые сказки, как они все умеют.


  1. Naves
    06.06.2018 22:54

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

    Неееет, только не это…
    Вы же должны были в процессе Google-Stackoverflow-Copy-Paste видеть это stackoverflow.com/a/1732454


    1. nckma Автор
      06.06.2018 23:04

      Нет тут такой проблемы, о которой говорится в статье на которую Вы ссылаетесь.
      Мне не нужно парсить именно конструкции html. Мне нужно только извлечь некоторые строки, которые начинаются с нужной подстроки. Только и всего.


  1. Naves
    06.06.2018 23:21

    В виртуалку нужно пробросить USB от кассового аппарата Вики Принт

    У вас уже есть фискальный аппарат. И вместо того, чтобы штатно через библиотеку Piritlib посылать несколько команд: открыть чек, продажа товара А, скидка, закрыть чек, вы строите карточный домик на PyAutoGUI?


    1. nckma Автор
      06.06.2018 23:31

      Документация «Программно-технический комплекс Pirit K Инструкция по программированию» — это 83 страницы. В документации 96 команд.
      Я это посмотрел. Считаю, что на решение с PyAutoGUI я потратил 2 дня и это точно должно работать. А вот когда я начну посылать низкоуровневые команды — не уверен.
      Я смогу это сделать за 2 месяца, врядли раньше.

      И еще… Для низкоуровневой разработки скорее всего мне понадобится эмулятор ФН МГМ, а он стоит 12-15 тысяч.


      1. Naves
        06.06.2018 23:47

        Из этих 96 вам нужно всего пять, плюс-минус ещё пара для получения текущего статуса и закрытия смен.
        Вам не нужно на низком уровне формировать посылки в ком-порт, работа ведётся через библиотеку. Эмулятор тоже не нужен, фискальник либо принял команду, либо отклонил. Успешные отправленные чеки, потом можно штатно аннулировать через софт путём возврата денег.
        Утилиту Fito смотрели? help.dreamkas.ru/hc/ru/articles/115000493149-Интеграция-кассовых-программ-с-ВИКИ-ПРИНТ-Ф


  1. Naves
    06.06.2018 23:39

    Del


    1. nckma Автор
      06.06.2018 23:49

      Наверняка Вы правы, возможно даже Вы таких драйверов написали десять штук и для Вас это пройденный путь, который теперь Вам уже кажется простым и понятным.
      Я бы даже по авантюрности своей души взялся такое писать, но боюсь если я случайно отправлю некорректный чек в ОФД меня будет ждать очень неприятный разговор с бухгалтером.
      В случае с PyAutoGUI я почти не рискую. Я могу проверить как кликает скрипт сколько угодно раз и только потом подключить реальную кассу к USB.

      PS: мне вот не понятно, почему (если все так просто как Вы говорите) производители касс не пришут консольное приложение, которое взяв в командной строке XML файл чека печатает его на кассе? Я по крайней мере нигде такого очевидного и простого не нашел. Или оно есть но я не знаю где смотреть? Ко многим кассам можно подключиться по SSH. Есть ли там консольная команда которая может печатать чек? В Дримкасе мне сказали, что это возможно, но документации не дадут.


      1. Naves
        07.06.2018 00:18

        Не знаю как дела обстоят сейчас, но ещё 10 лет назад с каждым фискальником от штрих-м в комплекте с драйвером шли исходники простой программы для печати чеков и работы с фискальником. У этой программы был один недостаток, в ней было натурально 96 кнопок для посылки каждой команды, тот самый buttonLogic. При желании даже можно было написать свою простую программу для работы на кассе.
        Пока не было 54фз, в программе реально нужно было только пара текстовых полей для ввода сумм, да несколько кнопок, продажа, аннулирование чека, возврат, Х и Z отчеты. Так вот таких простых программ в интернете найти нельзя было (или плохо искал) везде были только всякие платные решения с кучей функционала.
        Сейчас алгоритм работы с кассой не сильно изменился, посмотреть можно на любом форуме по 1с. Задача печати чека из коммандной строки, видимо одной половине пользователей не нужна, так у них уже есть кассовый софт. А другая половина просто это нигде не публикует, либо продают как модули к той же 1с.


        1. nckma Автор
          07.06.2018 00:21

          Если не затруднит, пришлите ссылку на пример такой программы.
          У меня такое странное чуство, что этот код — это некий секрет, которым стараются не делиться (ибо видимо это их хлеб).
          По крайней мере я не нашел такого примера. Может не знаю где искать.


          1. Naves
            07.06.2018 00:54

            Первое попавшееся конкретно по штрихам
            infostart.ru/public/617491
            github.com/shtrih-m/javapos_shtrih/wiki
            Вот и утилита для командной строки 1c.ruboard.ru/public/609030
            В вашем случае сложнее, не такая известная модель


            1. nckma Автор
              07.06.2018 01:19

              Спасибо большое…
              Вы знаете… Сколько я во всякие техподдержки обращался никто мне толком ничего не говорил.
              Вот Ваши ответы к моей криворукой статье дали мне гораздо больше полезного, чем все обращения в техподдержки и консультации продавцов касс.
              Java программа прямо дала мне надежду сделать все правильно.