Garradin Plugins

Check-in [c3c4a1252a]
Login

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:Ajout plugin caisse en alpha
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA1: c3c4a1252a0c58925d60ff945a6162b57cfd90a5
User & Date: bohwaz 2020-05-20 00:39:17
Context
2020-05-22
00:42
Mise à jour check-in: 12f779efb8 user: bohwaz tags: trunk
2020-05-20
00:39
Ajout plugin caisse en alpha check-in: c3c4a1252a user: bohwaz tags: trunk
2020-05-17
22:30
Soucis de fuseaux horaire locaux check-in: 79d5867829 user: bohwaz tags: trunk
Changes
Hide Diffs Unified Diffs Ignore Whitespace Patch

Added caisse/data.sql.























































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
INSERT INTO @PREFIX_categories VALUES (1, "Adhésion");
INSERT INTO @PREFIX_products VALUES (NULL, 1, "Adhésion normale", NULL, 1500, 1, NULL, NULL);
INSERT INTO @PREFIX_products VALUES (NULL, 1, "Adhésion réduite", NULL, 1000, 1, NULL, NULL);
INSERT INTO @PREFIX_products VALUES (NULL, 1, "Adhésion soutien", NULL, 2000, 1, NULL, NULL);

INSERT INTO @PREFIX_categories VALUES (2, "Forfaits coup de pouce vélo");
--INSERT INTO @PREFIX_products VALUES (4, 2, "Forfait révision vélo adhérent", NULL, 1500, 1, NULL, NULL);
INSERT INTO @PREFIX_products VALUES (NULL, 2, "Forfait réparation vélo occasion", 'Réglage d''étrier de frein ou patin
Lubrification de la câblerie frein
Nettoyage de la transmission
Lubrification de la câblerie dérailleur
Réglage d''un dérailleur et de son sélecteur de vitesse
Serrage contrôlé des plateaux
Serrage contrôlé du boîtier de pédalier
Remise en état de roue
Réglage du jeu de direction
Réglage de l''alignement du guidon', 3500, 1, NULL, NULL);

INSERT INTO @PREFIX_categories VALUES (3, "Pièces neuves");
INSERT INTO @PREFIX_products (category, name, price) VALUES
	(3, "Ampoule pour phare dynamo", 100),
	(3, "Antivol boa ou chaîne", 2000),
	(3, "Antivol U à clé ou à code", 2500),
	(3, "Câble de frein ou de dérailleur", 100),
	(3, "Capuchon de dynamo", 100),
	(3, "Chaîne 1-3v", 600),
	(3, "Chaîne 4-7v", 800),
	(3, "Chaîne 8-9v", 1000),
	(3, "Chambre à air", 500),
	(3, "Fond de jante (par roue)", 100),
	(3, "Gaine de frein (par câble)", 100),
	(3, "Gaine de dérailleur (par câble)", 200),
	(3, "Guidoline tissu noir", 300),
	(3, "Kit rustines", 300),
	(3, "Patin de frein", 200),
	(3, "Phares XLC mini (paire AV+AR)", 500),
	(3, "Phares Reelight", 3000),
	(3, "Pince à pantalon", 100),
	(3, "Pneu mini (550, 600)", 1500),
	(3, "Pneu standard (26"", 700, 28"", 650)", 1000),
	(3, "Mini pompe", 500),
	(3, "Selle", 1000),
	(3, "Sonnette classique (chromée)", 300),
	(3, "Sonnette basique (à languette, plastique noir)", 200),
	(3, "Tendeur", 300),
	(3, "Autre pièce neuve", 1000);

INSERT INTO @PREFIX_categories VALUES (4, "Pièces d'occasion");
INSERT INTO @PREFIX_products (category, name, price) VALUES
	(4, "Adaptateur (tige de selle, potence)", 100),
	(4, "Ampoule", 50),
	(4, "Attache (rapide, selle, roue, panier, siège)", 100),
	(4, "Axe (pédalier, roue)", 100),
	(4, "Béquille", 500),
	(4, "Boîtier de pédalier (ou cartouche)", 400),
	(4, "Butée réglable de gaine (frein, dérailleur)", 50),
	(4, "Câble (frein, dérailleur)", 50),
	(4, "Cadre nu", 1500),
	(4, "Cage à billes", 50),
	(4, "Cale-pied", 200),
	(4, "Carter de chaîne", 300),
	(4, "Cassette (seule)", 100),
	(4, "Chaîne", 300),
	(4, "Chambre à air", 100),
	(4, "Chariot de selle", 100),
	(4, "Clavette", 100),
	(4, "Corne de guidon", 100),
	(4, "Couvre-selle", 200),
	(4, "Cuvette de pédalier", 100),
	(4, "Dérailleur (avant, arrière)", 300),
	(4, "Dynamo", 200),
	(4, "Écarteur de danger", 200),
	(4, "Écrou d'axe de roue", 50),
	(4, "Étrier de frein (avec patins)", 500),
	(4, "Fond de jante (par roue)", 50),
	(4, "Fourche", 500),
	(4, "Gaine (frein, dérailleur, par câble)", 50),
	(4, "Garde-boue", 200),
	(4, "Guidon (nu)", 300),
	(4, "Jante (nue)", 400),
	(4, "Levier de frein", 200),
	(4, "Manette de dérailleur", 200),
	(4, "Manivelle gauche", 100),
	(4, "Manivelle droite", 300),
	(4, "Moyeu", 400),
	(4, "Panier", 500),
	(4, "Patin de frein", 100),
	(4, "Pédale (par pédale)", 100),
	(4, "Pédalier (sans pédales)", 400),
	(4, "Phare (sans ampoule)", 300),
	(4, "Pignon (cassette, roue libre)", 50),
	(4, "Plateau pour pédalier", 100),
	(4, "Pneu", 500),
	(4, "Poignée de guidon", 100),
	(4, "Pompe", 200),
	(4, "Porte-bagage", 500),
	(4, "Porte-bidon", 50),
	(4, "Potence", 300),
	(4, "Rayon (unité)", 50),
	(4, "Rayons (botte)", 500),
	(4, "Roue (nue, avec écrou ou attache)", 1000),
	(4, "Roue libre", 300),
	(4, "Roulettes vélo enfant (paire)", 200),
	(4, "Sacoche", 500),
	(4, "Selle", 500),
	(4, "Siège enfant", 1000),
	(4, "Tige de selle", 200),
	(4, "Autre pièce d'occasion", 100);

INSERT INTO @PREFIX_categories VALUES (5, "Vélo d'occasion");
INSERT INTO @PREFIX_products VALUES (NULL, 5, "Vélo d'occasion", NULL, 6000, 1, NULL, NULL);

INSERT INTO @PREFIX_methods VALUES (1, 'Espèces', NULL, NULL);
INSERT INTO @PREFIX_methods VALUES (2, 'Chèque', NULL, NULL);
INSERT INTO @PREFIX_methods VALUES (3, 'Coup de pouce vélo', 1500, 5000);

-- Ajout espèces/chèque
INSERT INTO @PREFIX_products_methods SELECT id, 1 FROM @PREFIX_products;
INSERT INTO @PREFIX_products_methods SELECT id, 2 FROM @PREFIX_products;

-- Coup de pouce vélo pièces détachées
INSERT INTO @PREFIX_products_methods SELECT id, 3 FROM @PREFIX_products WHERE category IN (3, 4) AND (
	(name LIKE 'Chaîne%')
	OR (name LIKE 'Chambre à air%')
	OR (name LIKE 'Pneu%')
	OR (name LIKE 'Selle%')
	OR (name LIKE 'Patin%')
	OR (name LIKE 'Gaine%')
	OR (name LIKE 'Câble%')
	OR (name LIKE 'Pédale%')
	OR (name LIKE 'Roue%')
	OR (name LIKE 'Manivelle%')
	OR (name LIKE 'Pédalier%')
	OR (name LIKE 'Manette%')
	OR (name LIKE 'Levier%')
	OR (name LIKE 'Guidon%')
	OR (name LIKE 'Fourche%')
	OR (name LIKE 'Cuvette%')
	OR (name LIKE 'Cassette%')
	OR (name LIKE 'Axe%')
	OR (name LIKE 'Attache%')
	OR (name LIKE 'Boîtier%')
	OR (name LIKE 'Butée%')
	OR (name LIKE 'Chariot%')
	OR (name LIKE 'Clavette%')
	OR (name LIKE 'Écrou%')
	OR (name LIKE 'Dérailleur%')
	OR (name LIKE 'Rayon%')
	OR (name LIKE 'Pignon%')
	OR (name LIKE 'Plateau%')
	OR (name LIKE 'Fond de jante%')
	OR (name IN ('Potence', 'Poignée de guidon', 'Tige de selle'))
);

INSERT INTO @PREFIX_products_methods SELECT id, 3 FROM @PREFIX_products WHERE category IN (1, 2);

Added caisse/garradin_plugin.ini.

















>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
nom="Caisse"
description="Gestion de caisse (version alpha !)"
auteur="BohwaZ"
url="https://garradin.eu/"
version="0.0.1"
menu=1
config=0
min_version="0.9.6"

Added caisse/install.php.





















>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
<?php

namespace Garradin;

use Garradin\Plugin\Caisse\POS;

$db = DB::getInstance();

$db->exec(POS::sql(file_get_contents(__DIR__ . '/schema.sql')));
$db->exec(POS::sql(file_get_contents(__DIR__ . '/data.sql')));

Added caisse/lib/Method.php.



























>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

namespace Garradin\Plugin\Caisse;

use Garradin\DB;

class Method
{
	static public function getList(): array
	{
		return DB::getInstance()->getAssoc(POS::sql('SELECT id, name FROM @PREFIX_methods ORDER BY name;'));
	}
}

Added caisse/lib/POS.php.









































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace Garradin\Plugin\Caisse;

use Garradin\DB;

class POS
{
	const TABLES_PREFIX = 'plugin_pos_';

	static public function sql(string $query): string
	{
		return str_replace('@PREFIX_', self::TABLES_PREFIX, $query);
	}

	static public function tbl(string $table): string
	{
		return self::TABLES_PREFIX . $table;
	}
}

Added caisse/lib/Product.php.



























































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

namespace Garradin\Plugin\Caisse;

use Garradin\DB;

class Product
{
	static public function listByCategory(): array
	{
		$db = DB::getInstance();
		$categories = $db->getAssoc(POS::sql('SELECT id, name FROM @PREFIX_categories ORDER BY name;'));
		$products = $db->get(POS::sql('SELECT * FROM @PREFIX_products ORDER BY category, name;'));

		$list = [];

		foreach ($products as $product) {
			$cat = $categories[$product->category];

			if (!array_key_exists($cat, $list)) {
				$list[$cat] = [];
			}

			$list[$cat][] = $product;
		}

		return $list;
	}
}

Added caisse/lib/Session.php.























































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php

namespace Garradin\Plugin\Caisse;

use Garradin\DB;

class Session
{
	public function open(int $user_id, int $amount): int
	{
		$db = DB::getInstance();
		$db->insert(POS::tbl('sessions'), [
			'open_user'   => $user_id,
			'open_amount' => $amount,
		]);

		return $db->lastInsertId();
	}

	public function getCurrent()
	{
		$db = DB::getInstance();
		return $db->first(POS::sql('SELECT * FROM @PREFIX_sessions WHERE closed IS NULL LIMIT 1;'));
	}

	public function close(int $id, int $amount)
	{
		$db = DB::getInstance();

		if ($db->test(POS::tbl('tabs'), 'session = ? AND closed IS NULL', $id)) {
			throw new UserException('Il y a des notes qui ne sont pas clôturées.');
		}
	}

	public function listPayments(int $id)
	{
		return DB::getInstance()->get(POS::sql('SELECT tp.*, m.name
			FROM @PREFIX_tabs_payments tp
			INNER JOIN @PREFIX_tabs t ON tp.tab = t.id AND t.session = ?
			INNER JOIN @PREFIX_methods m ON m.id = tp.method
			ORDER BY m.name, tp.date;', $id));
	}

	public function listPaymentTotals(int $id)
	{
		return DB::getInstance()->get(POS::sql('SELECT SUM(tp.amount), m.name FROM @PREFIX_tabs_payments tp
			INNER JOIN @PREFIX_tabs t ON tp.tab = t.id AND t.session = ?
			INNER JOIN @PREFIX_methods m ON m.id = tp.method
			GROUP BY tp.method
			ORDER BY m.name;', $id));
	}

	public function listTabsTotals(int $id)
	{
		return DB::getInstance()->get(POS::sql('SELECT *,
			(SELECT SUM(qty * price) FROM @PREFIX_tabs_items WHERE tab = t.id) AS total
			FROM @PREFIX_tabs t WHERE session = ? AND closed IS NULL ORDER BY opened;', $id));
	}
}

Added caisse/lib/Tab.php.













































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
<?php

namespace Garradin\Plugin\Caisse;

use Garradin\DB;
use Garradin\UserException;

class Tab
{
	protected $id;

	public function __construct(int $id)
	{
		$this->id = $id;
	}

	public function getRemainder(): int
	{
		return (int) DB::getInstance()->firstColumn(POS::sql('SELECT
			(SELECT SUM(price * qty) FROM @PREFIX_tabs_items WHERE tab = ?)
			- COALESCE((SELECT SUM(amount) FROM @PREFIX_tabs_payments WHERE tab = ?), 0);'), $this->id, $this->id);
	}

	public function addItem(int $id)
	{
		$db = DB::getInstance();
		$product = $db->first(POS::sql('SELECT * FROM @PREFIX_products WHERE id = ?'), $id);

		return $db->insert(POS::tbl('tabs_items'), [
			'tab'     => $this->id,
			'product' => (int)$product->id,
			'qty'     => (int)$product->qty,
			'price'   => (int)$product->price,
		]);
	}

	public function removeItem(int $id)
	{
		return DB::getInstance()->delete(POS::tbl('tabs_items'), 'id = ? AND tab = ?', $id, $this->id);
	}

	public function updateItemQty(int $id, int $qty)
	{
		$db = DB::getInstance();
		return $db->update(POS::tbl('tabs_items'),
			['qty' => $qty],
			$db->where('id', $id));
	}

	public function updateItemPrice(int $id, int $price)
	{
		$db = DB::getInstance();
		return $db->update(POS::tbl('tabs_items'),
			['price' => $price],
			$db->where('id', $id));
	}

	public function listItems()
	{
		return DB::getInstance()->get(POS::sql('SELECT ti.*, p.name, p.description,
			ti.qty * ti.price AS total,
			GROUP_CONCAT(pm.method, \',\') AS methods,
			c.name AS category
			FROM @PREFIX_tabs_items ti
			INNER JOIN @PREFIX_products p ON ti.product = p.id
			INNER JOIN @PREFIX_products_methods pm ON pm.product = p.id
			INNER JOIN @PREFIX_categories c ON c.id = p.category
			WHERE ti.tab = ?
			GROUP BY ti.id
			ORDER BY ti.id;'), $this->id);
	}

	public function pay(int $method_id, int $amount, ?string $reference)
	{
		if ('' === trim($reference)) {
			$reference = NULL;
		}

		$options = $this->listPaymentOptions();

		if (empty($options[$method_id]->amount)) {
			throw new UserException('Ce moyen de paiement ne peut pas être utilisé pour cette note');
		}
		elseif ($amount > $options[$method_id]->amount) {
			$a = $options[$method_id]->amount;
			throw new UserException(sprintf('Ce moyen de paiement ne peut être utilisé pour un montant supérieur à %d,%02d€', (int) ($a/100), (int) ($a%100)));
		}

		return DB::getInstance()->insert(POS::tbl('tabs_payments'), [
			'tab'       => $this->id,
			'method'    => $method_id,
			'amount'    => $amount,
			'reference' => $reference,
		]);
	}

	public function removePayment(int $id)
	{
		return DB::getInstance()->delete(POS::tbl('tabs_payments'), 'id = ? AND tab = ?', $id, $this->id);
	}

	public function listPayments()
	{
		return DB::getInstance()->get(POS::sql('SELECT tp.*, m.name
			FROM @PREFIX_tabs_payments tp
			INNER JOIN @PREFIX_methods m ON m.id = tp.method
			WHERE tp.tab = ?;'), $this->id);
	}

	public function listPaymentOptions()
	{
		$remainder = $this->getRemainder();
		return DB::getInstance()->getGrouped(POS::sql('SELECT id, *,
			CASE
				WHEN max IS NOT NULL AND paid >= max THEN 0
				WHEN max IS NOT NULL AND payable > max THEN max
				WHEN min IS NOT NULL AND payable < min THEN 0
				ELSE MIN(:left, payable) END AS amount
			FROM (SELECT m.*, SUM(pt.amount) AS paid, SUM(i.qty * i.price) AS payable
				FROM @PREFIX_methods m
				INNER JOIN @PREFIX_products_methods pm ON pm.method = m.id
				INNER JOIN @PREFIX_tabs_items i ON i.product = pm.product AND i.tab = :id
				LEFT JOIN @PREFIX_tabs_payments AS pt ON pt.tab = i.tab AND m.id = pt.method
				GROUP BY m.id
			);'), ['id' => $this->id, 'left' => $remainder]);
	}

	static public function open(int $session_id)
	{
		$db = DB::getInstance();
		$db->insert(POS::tbl('tabs'), [
			'session' => $session_id,
		]);

		return $db->lastInsertRowID();
	}

	public function rename(string $new_name) {
		$new_name = trim($new_name);
		$db = DB::getInstance();
		return $db->update(POS::tbl('tabs'), ['name' => $new_name], $db->where('id', $this->id));
	}

	public function close() {
		$remainder = $this->getRemainder();

		if ($remainder != 0) {
			throw new UserException(sprintf("Impossible de clôturer la note: reste %s € à régler.", format_amount($remainder)));
		}

		return DB::getInstance()->preparedQuery(POS::sql('UPDATE @PREFIX_tabs SET closed = datetime(\'now\',\'localtime\') WHERE id = ?;'), [$this->id]);
	}

	public function delete() {
		$db = DB::getInstance();
		if ($db->test(POS::tbl('tabs'), 'closed IS NULL AND id = ?', $this->id)) {
			throw new UserException('Impossible de supprimer une note qui n\'est pas close');
		}

		$db->delete(POS::tbl('tabs'), 'id = ?', $this->id);
	}

	static public function listForSession(int $session_id) {
		return DB::getInstance()->getGrouped(POS::sql('SELECT id, *, COALESCE((SELECT SUM(qty*price) FROM @PREFIX_tabs_items WHERE tab = @PREFIX_tabs.id), 0) AS total FROM @PREFIX_tabs WHERE session = ? ORDER BY closed IS NOT NULL, opened DESC;'), $session_id);
	}
}

Added caisse/schema.sql.















































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
-- Amounts are stored as integers, including cents, eg. 15.99€ will be stored as 1599

CREATE TABLE IF NOT EXISTS @PREFIX_categories (
	id INTEGER NOT NULL PRIMARY KEY,
	name TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS @PREFIX_products (
	-- Products
	id INTEGER NOT NULL PRIMARY KEY,
	category INTEGER NOT NULL REFERENCES @PREFIX_categories (id) ON DELETE CASCADE,
	name TEXT NOT NULL,
	description TEXT NULL,
	price INTEGER NOT NULL,
	qty INTEGER NOT NULL DEFAULT 1, -- Default quantity when adding to cart
	stock INTEGER NULL, -- NULL if it's not subject to stock change (like a membership)
	image BLOB NULL
);

CREATE TABLE IF NOT EXISTS @PREFIX_products_stock_history (
	-- History of stock changes for a product
	product INTEGER NOT NULL REFERENCES @PREFIX_products (id) ON DELETE CASCADE,
	change INTEGER NOT NULL, -- Number of items removed or added to stock: can be negative or positive
	date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Date of change
	item INTEGER NULL REFERENCES @PREFIX_tabs (id) ON DELETE CASCADE, -- Link to item in a customer tab
	event INTEGER NULL REFERENCES @PREFIX_stock_events (id) ON DELETE CASCADE -- Link to stock event
);

CREATE TABLE IF NOT EXISTS @PREFIX_stock_events (
	-- Stock events (eg. delivery from supplier)
	id INTEGER NOT NULL PRIMARY KEY,
	date TEXT NOT NULL,
	description TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS @PREFIX_methods (
	-- Payment methods
	id INTEGER NOT NULL PRIMARY KEY,
	name TEXT NOT NULL,
	min INTEGER NULL,
	max INTEGER NULL
);

CREATE TABLE IF NOT EXISTS @PREFIX_products_methods (
	-- Link between products and available payment methods
	product INTEGER NOT NULL REFERENCES @PREFIX_products (id) ON DELETE CASCADE,
	method INTEGER NOT NULL REFERENCES @PREFIX_methods (id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS @PREFIX_sessions (
	-- Cash register sessions
	id INTEGER NOT NULL PRIMARY KEY,
	opened TEXT NOT NULL DEFAULT (datetime('now','localtime')),
	closed TEXT NULL,
	open_user INTEGER NULL,
	open_amount INTEGER NULL,
	close_amount INTEGER NULL
);

CREATE TABLE IF NOT EXISTS @PREFIX_tabs (
	-- Customer tabs (or carts)
	id INTEGER NOT NULL PRIMARY KEY,
	session INTEGER NOT NULL REFERENCES @PREFIX_sessions (id) ON DELETE CASCADE,
	name TEXT NULL,
	opened TEXT NOT NULL DEFAULT (datetime('now','localtime')),
	closed TEXT NULL -- Closed if total == paid
);

CREATE TABLE IF NOT EXISTS @PREFIX_tabs_items (
	-- Items in a customer tab
	id INTEGER NOT NULL PRIMARY KEY,
	tab INTEGER NOT NULL REFERENCES @PREFIX_tabs (id) ON DELETE CASCADE,
	added TEXT NOT NULL DEFAULT (datetime('now','localtime')),
	product INTEGER NOT NULL REFERENCES @PREFIX_products (id) ON DELETE CASCADE,
	qty INTEGER NOT NULL,
	price INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS @PREFIX_tabs_payments (
	-- Payments for a tab
	id INTEGER NOT NULL PRIMARY KEY,
	tab INTEGER NOT NULL REFERENCES @PREFIX_tabs (id) ON DELETE CASCADE,
	method INTEGER NOT NULL REFERENCES @PREFIX_methods (id) ON DELETE SET NULL,
	date TEXT NOT NULL DEFAULT (datetime('now','localtime')),
	amount INTEGER NOT NULL, -- Can be negative for a refund
	reference TEXT NULL
);

Added caisse/templates/_head.tpl.























>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
<head>
        <meta charset="utf-8" />
        <title>Caisse</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, target-densitydpi=device-dpi" />
        <link rel="stylesheet" type="text/css" href="{$url}static/main.css?{$version_hash}" media="all" />
        <link rel="icon" type="image/png" href="{$url}static/icon.png" />
</head>

<body>

Added caisse/templates/invoice.tpl.































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
<head>
	<meta charset="utf-8" />
	<title>Facture</title>
	<style type="text/css">
	{literal}
	@page {
		size: A4;
		margin: 1.5cm;
		@bottom {
			content: "Page " counter(page) " / " counter(pages);
			font-size: 8pt;
			margin-bottom: 10mm;
			text-align: center;
		}

	}
	* { margin: 0; padding: 0; }
	body {
		font-family: Arial, Helvetica, sans-serif;
		background: #fff;
		color: #000;
		font-size: 10pt;
	}
	header {
		display: flex;
		align-items: center;
		justify-content: center;
	}
	header img {
		width: 8em;
	}
	header div {
		text-align: center;
		margin: 1em;
	}
	h1 {
		font-size: 1.2rem;
	}
	h2 {
		font-size: 1rem;
		font-weight: normal;
	}
	h3 {
		font-size: .8rem;
		font-weight: normal;
	}
	.details {
		margin: 1rem 0;
		text-align: center;
	}

	table {
		margin: 1rem auto;
		border-collapse: collapse;
		width: 100%;
	}
	table tr {
		border: .1rem solid #000;
	}

	table th, table td {
		padding: .3rem .6rem;
		text-align: left;
		border-right: .1rem dotted #999;
	}

	table thead {
		background: #000;
		color: #fff;
	}
	table tr.foot {
		background: #eee;
		font-size: 12pt;
	}
	{/literal}
	</style>
</head>

<body>

<header>
	<img src="data:image/png;base64,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
" />
	<div>
		<h1>La rustine — atelier associatif de réparation de vélos</h1>
		<h2>Association « loi 1901 » à but non lucratif — SIRET 538 625 773 00022</h2>
		<h3>5 rue du Havre, 21000 DIJON — 03 73 27 03 66 — contact@larustine.org — http://larustine.org/</h3>
	</div>
</header>

<section class="details">
	<h1>Facture n°C{$tab.id}</h1>
	<h2>Entretien vélo dans le cadre du "Coup de pouce Vélo - Réparation"</h2>
	<h3>Adhérent : {$tab.name}</h3>
	<h4>Date de la facture : {$tab.opened|date_format:"%d/%m/%Y"} — Date d'échéance : {$tab.opened|date_format:"%d/%m/%Y"}</h4>
</section>

<section class="items">
	<form method="post">
	<table class="list">
		<thead>
			<th>Dénomination</th>
			<td>Éligible Coup de pouce vélo</td>
			<td>Qté</td>
			<td>Prix</td>
			<td>Total</td>
		</thead>
		<tbody>
		{foreach from=$items item="item"}
			<tr>
				<th>{$item.name}</th>
				<td>{$item.methods|raw|show_methods}</td>
				<td>{$item.qty}</td>
				<td>{$item.price|escape|pos_money}</td>
				<td>{$item.total|escape|pos_money}</td>
			</tr>
			{if $item.description}
			<tr>
				<td colspan="5">
					{$item.description|escape|nl2br}
				</td>
			</tr>
			{/if}
		{/foreach}
			<tr class="foot">
				<th>TVA</th>
				<td colspan="4"><em>Association exonérée des impôts commerciaux</em></td>
			</tr>
			<tr class="foot">
				<th colspan="4">Total</th>
				<td>{$tab.total|escape|pos_money}</td>
			</tr>
			{foreach from=$existing_payments item="payment"}
			<tr class="foot">
				<th>{$payment.name}</th>
				<td colspan="3"><em>Réf. {$payment.reference}</em></td>
				<td>{$payment.amount|escape|pos_money}</td>
			</tr>
			{/foreach}
			{foreach from=$payment_options item="option"}
			<tr class="foot">
				<th colspan="4">Déduction « Coup de pouce vélo - réparation »</th>
				<td>{$option.amount|escape|pos_money}</td>
			</tr>
			{/foreach}
			{if $remainder_after}
			<tr class="foot">
				<th colspan="4">Reste à payer</th>
				<td>{$remainder_after|escape|pos_money}</td>
			</tr>
			{/if}
		</tbody>
	</table>
</section>

</body>
</html>

Added caisse/templates/session_open.tpl.

































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{include file="admin/_head.tpl" title="Ouverture de caisse" current="plugin_%s"|args:$plugin.id}

<form method="post" action="">
<fieldset>
	<legend>Ouvrir la caisse</legend>
	<dl>
		<dt><label for="f_amount">Solde de la caisse à l'ouverture</label></dt>
		<dd><input type="text" pattern="\d+(,\d+)?" name="amount" id="f_amount" size="5" placeholder="42,32" />&nbsp;€</dd>
	</dl>
	<p class="submit">
		<input type="submit" name="open" value="Enregistrer le paiement" />
	</p>
</fieldset>
</form>

{include file="admin/_foot.tpl"}

Added caisse/templates/tab.tpl.































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
{include file="admin/_head.tpl" title="Caisse" current="plugin_%s"|args:$plugin.id}

<ul class="actions">
	<li><a href="{$self_url_no_qs}?new"><strong>Nouvelle note</strong></a></li>
{foreach from=$tabs item="tab"}
	<li class="{if $tab.id == $tab_id}current{/if} {if $tab.closed}closed{/if}">
		<a href="{$self_url_no_qs}?change={$tab.id}">
			{$iteration}. {$tab.opened|date_format:"%H:%M"}
			{if $tab.total} — {$tab.total|escape|pos_money}{/if}
			{if $tab.name} — {$tab.name}{/if}
		</a>
	</li>
{/foreach}
</ul>

{if $tab_id}
<section class="pos">
	<section class="tab">
		<header>
			<h2>
				{$current_tab.opened|date_format:"%H:%M"}
				{if $current_tab.closed}
				&rarr; {$current_tab.closed|date_format:"%H:%M"}
				{/if}
				— {$current_tab.name}
			</h2>
			<div>
				<form method="post">
				<input type="submit" name="rename" value="Renommer" />
				{if !$remainder && !$current_tab.closed}
					<input type="submit" name="close" value="Clore la note" />
				{/if}
				{if !count($existing_payments)}
					<input type="submit" name="delete" value="Supprimer la note" />
				{/if}
				</form>
				<form method="post" action="./pdf.php?id={$current_tab.id}">
					<input type="submit" value="Facture PDF" />
				</form>
			</div>
		</header>

		<section class="items">
			<form method="post">
			<table class="list">
				<thead>
					<th></th>
					<td>Qté</td>
					<td>Prix</td>
					<td>Total</td>
					<td></td>
				</thead>
				<tbody>
				{foreach from=$items item="item"}
				<tr>
					<th>{$item.name} {$item.methods|raw|show_methods}</th>
					<td>{if !$current_tab.closed}<input type="submit" name="change_qty[{$item.id}]" value="{$item.qty}" />{else}{$item.qty}{/if}</td>
					<td>{if !$current_tab.closed}<input type="submit" name="change_price[{$item.id}]" value="{$item.price|escape|pos_money}" />{else}{$item.price|escape|pos_money}{/if}</td>
					<td>{$item.total|escape|pos_money}</td>
					<td class="actions">{if !$current_tab.closed}<a class="icn" href="?delete_item={$item.id}" title="Supprimer">✘</a>{/if}</td>
				</tr>
				{/foreach}
				</tbody>
				<tfoot>
					<tr>
						<th>Total</th>
						<td></td>
						<td></td>
						<td colspan="2">{$current_tab.total|escape|pos_money}</td>
					</tr>
				</tfoot>
			</table>
		</section>

		<section class="payments">
			{if $existing_payments}
			<h2>Paiements effectués</h2>
			<table class="list">
				<tbody>
				{foreach from=$existing_payments item="payment"}
				<tr>
					<th>{$payment.name}</th>
					<td>{$payment.amount|escape|pos_money}</td>
					<td><em>{$payment.reference}</em></td>
					<td class="actions">{if !$current_tab.closed}<a class="icn" href="?delete_payment={$payment.id}" title="Supprimer">✘</a>{/if}</td>
				</tr>
				{/foreach}
				</tbody>
			</table>
			{/if}

			{if $remainder}
			<form method="post">
				<fieldset>
					<legend>Reste {$remainder|escape|pos_money} à payer</legend>
					<dl>
						<dt>Moyen de paiement</dt>
						<dd>
							<select name="method_id">
								{foreach from=$payment_options item="method"}
								<option value="{$method.id}" data-amount="{$method.amount|pos_amount}">{$method.name} (jusqu'à {$method.amount|escape|pos_money})</option>
								{/foreach}
							</select>
						</dd>
						<dt>Montant</dt>
						<dd>
							<input type="text" pattern="\d+(,\d+)?" name="amount" id="f_method_amount" value="{$remainder|pos_amount}" required="required" size="5" /> €
						</dd>
						<dt>Référence du paiement (numéro de chèque…)</dt>
						<dd>
							<input type="text" name="reference" />
						</dd>
					</dl>
					<p class="submit">
						<input type="submit" name="pay" value="Enregistrer le paiement" />
					</p>
				</fieldset>
			</form>
			{/if}
		</section>
	</section>

	{if !$current_tab.closed}
	<section class="products">
		<form method="get" action="">
			<input type="text" name="q" placeholder="Recherche rapide" />
		</form>
		<form method="post" action="">
		{foreach from=$products_categories key="category" item="products"}
			<section>
				<h2 class="ruler">{$category}</h2>

				<div>
				{foreach from=$products item="product"}
					<button name="add_item[{$product.id}]">
						<h3>{$product.name}</h3>
						<h4>{$product.price|escape|pos_money}</h4>
						{if $product.image}
							<figure><img src="{$product.image|image_base64}" alt="" /></figure>
						{/if}
					</button>
				{/foreach}
				</div>
			</section>
		{/foreach}
		</form>
	</section>
	{/if}
</section>
{/if}

<script type="text/javascript">
{literal}
var fr = document.querySelector('input[name="rename"]');

if (fr) {
	fr.onclick = function(e) {
		fr.value = prompt("Nouveau nom ?");
	}
}

document.querySelectorAll('input[name*="change_qty"], input[name*="change_price"]').forEach((elm) => {
	elm.onclick = (e) => {
		var v = prompt('?');
		if (v === null) return false;
		elm.value = v;
	};
});

var pm = document.querySelector('select[name="method_id"]');

if (pm) {
	pm.onchange = (e) => {
		document.querySelector('#f_method_amount').value = pm.options[pm.selectedIndex].getAttribute('data-amount');
	};
}

var q = document.querySelector('input[name="q"]');

RegExp.escape = function(string) {
  return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')
};

function normalizeString(str) {
	return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
}

if (q) {
	q.onkeyup = (e) => {
		var search = new RegExp(RegExp.escape(normalizeString(q.value)), 'i');

		document.querySelectorAll('.products button h3').forEach((elm) => {
			if (normalizeString(elm.innerText).match(search)) {
				elm.parentNode.style.display = null;
			}
			else {
				elm.parentNode.style.display = 'none';
			}
		})
	};

	q.focus();
}
{/literal}
</script>

{include file="admin/_foot.tpl"}

Added caisse/uninstall.php.

































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace Garradin;

use Garradin\Plugin\Caisse\POS;

$db = DB::getInstance();

preg_match_all('/CREATE TABLE IF NOT EXISTS (@PREFIX_\w+)/', file_get_contents(__DIR__ . '/schema.sql'), $match, PREG_PATTERN_ORDER);
$tables = array_reverse($match[1]);

$db->exec('PRAGMA foreign_keys = OFF');

foreach ($tables as $table) {
	$db->exec(POS::sql(sprintf('DROP TABLE IF EXISTS %s;', $table)));
}

Added caisse/www/admin/_inc.php.















































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php

namespace Garradin\Plugin\Caisse;

use Garradin\Utils;

function reload() {
	Utils::redirect(Utils::getSelfURL(false));
}

function get_amount(string $amount): int {
	$a = preg_replace('/[^\d,]/', '', $amount);
	$a = explode('.', $a);
	$a = sprintf('%d%02d', $a[0], @$a[1]);
	return $a;
}

function pos_amount(int $a): string {
	return sprintf("%d,%02d", (int) ($a/100), (int) ($a%100));
}

function pos_money(?int $a): string {
	return $a === null ? '' : pos_amount($a) . '&nbsp;€';
}

$s = new Session;
$pos_session = $s->getCurrent();

if (!$pos_session && !defined('SESSION_CREATE')) {
	Utils::redirect(Utils::plugin_url(['file' => 'session.php']));
}

$tpl->register_modifier('pos_money', __NAMESPACE__ . '\\pos_money');
$tpl->register_modifier('pos_amount', __NAMESPACE__ . '\\pos_amount');
$tpl->register_modifier('image_base64', function (string $blob) {
	return 'data:image/png;base64,' . base64_encode($blob);
});

$tpl->assign('plugin_css', ['style.css']);

Added caisse/www/admin/index.php.









>
>
>
>
1
2
3
4
<?php

require __DIR__ . '/_inc.php';

Added caisse/www/admin/pdf.php.















































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<?php

namespace Garradin;

use Garradin\Plugin\Caisse\Tab;

require __DIR__ . '/_inc.php';

$tab = new Tab(qg('id'));

$tabs = Tab::listForSession($pos_session->id);
$current_tab = $tabs[qg('id')];

if ('' === trim($current_tab->name)) {
	throw new UserException('La note n\'a pas de nom associé : impossible de produire la facture');
}

if (!shell_exec('which chromium')) {
	die('Impossible de trouver Chromium');
}

$tpl->assign('tab', $current_tab);
$tpl->assign('items', $tab->listItems());
$tpl->assign('existing_payments', $tab->listPayments());
$remainder = $tab->getRemainder();
$options = $tab->listPaymentOptions();

foreach ($options as $k => &$option) {
	if ($option->id != 3) {
		unset($options[$k]);
		continue;
	}

	$eligible = $option->amount;
}

if (empty($eligible)) {
	throw new UserException('Rien n\'est éligible à Coup de pouce vélo');
}

$remainder_after = $remainder - $eligible;

$tpl->assign('remainder', $remainder);
$tpl->assign('remainder_after', $remainder_after);
$tpl->assign('payment_options', $options);

$tpl->register_modifier('show_methods', function ($m) {
	$m = explode(',', $m);
	if (in_array(3, $m)) {
		return '<i>Oui</i>';
	}
});

$result = $tpl->fetch(PLUGIN_ROOT . '/templates/invoice.tpl');

//echo $result; exit;

$descriptorspec = array(
   0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
   1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
   2 => array('pipe', 'w'),
);

$cmd = 'prince -o - -';
$process = proc_open($cmd, $descriptorspec, $pipes);

if (is_resource($process)) {
    // $pipes now looks like this:
    // 0 => writeable handle connected to child stdin
    // 1 => readable handle connected to child stdout

    fwrite($pipes[0], $result);
    fclose($pipes[0]);

    $pdf_content = stream_get_contents($pipes[1]);
    fclose($pipes[1]);

    // It is important that you close any pipes before calling
    // proc_close in order to avoid a deadlock
    $return_value = proc_close($process);

    header('Content-type: application/pdf');
    //header(sprintf('Content-Length: %d', strlen($pdf_content)));
    header(sprintf('Content-Disposition: attachment; filename="Facture - %d.pdf"', qg('id')));
    echo $pdf_content;
}

Added caisse/www/admin/session.php.































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

namespace Garradin;

use function Garradin\Plugin\Caisse\get_amount;

define('SESSION_CREATE', true);
require __DIR__ . '/_inc.php';

$s = new Plugin\Caisse\Session;

if (!empty($_POST['open'])) {
	$s->open($session->getUser()->id, get_amount(f('amount')));
	Utils::redirect(Utils::plugin_url(['file' => 'tab.php']));
}
elseif (!empty($_POST['close'])) {
	$s->close($pos_session->id);
}

$tpl->assign('pos_session', $pos_session);

if ($pos_session) {
	$tpl->assign('payments', $s->listPayments($pos_session->id));
	$tpl->assign('payments_totals', $s->listPaymentTotals($pos_session->id));
	$tpl->assign('tabs', $s->listTabsTotals($pos_session->id));

	$tpl->display(PLUGIN_ROOT . '/templates/session.tpl');
}
else {
	$tpl->display(PLUGIN_ROOT . '/templates/session_open.tpl');
}

Added caisse/www/admin/style.css.































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
.pos {
	display: grid;
	grid-template-columns: 1fr 2fr;
	grid-column-gap: 1rem;
}

.pos header {
	display: flex;
	justify-content: space-between;
	margin-bottom: 1rem;
}

.pos .products button {
    background: #eee;
    border: .2rem solid #ddd;
	border-radius: .5rem;
	padding: .5rem;
	margin: .3rem;
	flex: 1;
	transition: background .2s;
	min-width: 9rem;
}

.pos .products button h4 {
	color: #666;
}

.pos .products button:hover {
	background: #fee;
	border-color: #fcc;
}

.pos .products section div {
	display: flex;
	flex-grow: 0;
	flex-wrap: wrap;
}

.pos .products h2 {
	opacity: 0.4;
	margin: 1rem 0;
}


ul.actions li.closed a {
	text-decoration: line-through;
	opacity: 0.5;
}

input[name="q"] {
	font-size: 2rem;
}
.pos form {
	display: inline;
}

.pos .items i {
	font-weight: normal;
	font-size: 2rem;
	font-style: normal;
	color: darkred;
	line-height: 1rem;
}

Added caisse/www/admin/tab.php.



























































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<?php

namespace Garradin;

use Garradin\Plugin\Caisse\Tab;
use Garradin\Plugin\Caisse\Product;

use function Garradin\Plugin\Caisse\{reload,get_amount};

require __DIR__ . '/_inc.php';

if ($tab_id = $session->get('pos_tab_id')) {
	$tab = new Tab($tab_id);
}

if (!empty($_POST['add_item'])) {
	$tab->addItem((int)key($_POST['add_item']));
	reload();
}
elseif (qg('delete_item')) {
	$tab->removeItem((int)qg('delete_item'));
	reload();
}
elseif (!empty($_POST['change_qty'])) {
	$tab->updateItemQty((int)key($_POST['change_qty']), (int)current($_POST['change_qty']));
	reload();
}
elseif (!empty($_POST['change_price'])) {
	$tab->updateItemPrice((int)key($_POST['change_price']), (int)get_amount(current($_POST['change_price'])));
	reload();
}
elseif (!empty($_POST['pay'])) {
	$tab->pay((int)$_POST['method_id'], get_amount(f('amount')), $_POST['reference']);
	reload();
}
elseif (qg('delete_payment')) {
	$tab->removePayment((int) qg('delete_payment'));
	reload();
}
elseif (null !== qg('new')) {
	$id = Tab::open($pos_session->id);
	$session->set('pos_tab_id', $id);
	reload();
}
elseif (!empty($_GET['change'])) {
	$session->set('pos_tab_id', (int) $_GET['change']);
	reload();
}
elseif (!empty($_POST['rename'])) {
	$tab->rename($_POST['rename']);
	reload();
}
elseif (!empty($_POST['close'])) {
	$tab->close();
	$remainder = $tab->getRemainder();
	$session->set('pos_tab_id', null);
	reload();
}
elseif (!empty($_POST['delete'])) {
	$tab->delete();
	$session->set('pos_tab_id', null);
	reload();
}

$tabs = Tab::listForSession($pos_session->id);

if ($tab_id && !isset($tabs[$tab_id])) {
	$tab_id = null;
	$session->set('pos_tab_id', null);
}

$tpl->assign('pos_session', $pos_session);
$tpl->assign('tab_id', $tab_id);

$tpl->assign('products_categories', Product::listByCategory());
$tpl->assign('tabs', $tabs);

if ($tab_id) {
	$tpl->assign('current_tab', $tabs[$tab_id]);
	$tpl->assign('items', $tab->listItems());
	$tpl->assign('existing_payments', $tab->listPayments());
	$tpl->assign('remainder', $tab->getRemainder());
	$tpl->assign('payment_options', $tab->listPaymentOptions());
}

$tpl->register_modifier('show_methods', function ($m) {
	$m = explode(',', $m);
	if (in_array(3, $m)) {
		return '<i>🚲</i>';
	}
});

$tpl->display(PLUGIN_ROOT . '/templates/tab.tpl');