diff --git a/.gitignore b/.gitignore index 3268211..9eccd17 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .*.sw? +*~ diff --git a/barcode-generator/barcode-generator.py b/barcode-generator/barcode-generator.py index ff42955..f2d30a3 100755 --- a/barcode-generator/barcode-generator.py +++ b/barcode-generator/barcode-generator.py @@ -7,16 +7,16 @@ from subprocess import Popen, PIPE import sys svghead = """ - + """ svgfoot = """ """ -width = 4 -scalex = 1.2 -scaley = 1.2 +width = 5 +scalex = 0.8 +scaley = 0.8 p = 0 i = 0 @@ -27,7 +27,7 @@ lines = sys.stdin.readlines() for idx in xrange(len(lines)): items = lines[idx].strip().split(';') - if idx % 8 == 0: + if idx % 30 == 0: if f and not f.closed: f.write(svgfoot) f.close() @@ -36,9 +36,9 @@ for idx in xrange(len(lines)): i = 0 j = 0 f.write(svghead) - elem = Popen(('zint','--directsvg','--notext', '-d', items[1]), stdout = PIPE).communicate()[0].split('\n') + elem = Popen(('./zint','--directsvg','--notext', '-d', items[1]), stdout = PIPE).communicate()[0].split('\n') elem = elem[8:-2] - elem[0] = elem[0].replace('id="barcode"', 'transform="matrix(%f,0,0,%f,%f,%f)"' % (scalex, scaley, 50+i*285 , 180+j*285) ) + elem[0] = elem[0].replace('id="barcode"', 'transform="matrix(%f,0,0,%f,%f,%f)"' % (scalex, scaley, 50+i*140 , 180+j*140) ) elem.insert(-1, ' %s' % items[0]) f.write('\n'.join(elem)+'\n\n') i += 1 diff --git a/barcode-generator/howto.txt b/barcode-generator/howto.txt new file mode 100644 index 0000000..4dfba63 --- /dev/null +++ b/barcode-generator/howto.txt @@ -0,0 +1,9 @@ +on brmbar: +select distinct barcode from barcodes b, transactions t, accounts a where t.responsible=a.id and time>'2015-01-01' and b.account=a.id order by barcode asc; + +run this locally and paste output of previous command: + +while read tmp; do echo "$tmp;$tmp";done|grep -v overflow|python2 ./barcode-generator.py + +print resulting SVG files + diff --git a/brmbar3/.gitignore b/brmbar3/.gitignore index bee8a64..4a5b2ab 100644 --- a/brmbar3/.gitignore +++ b/brmbar3/.gitignore @@ -1 +1,3 @@ __pycache__ +*.log +brmbar/*.pyc diff --git a/brmbar3/COPYING b/brmbar3/COPYING new file mode 100644 index 0000000..4c46acf --- /dev/null +++ b/brmbar3/COPYING @@ -0,0 +1,343 @@ +Distribution under GPLv2 or any later version is acceptable for this +software. + + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/brmbar3/INSTALL.md b/brmbar3/INSTALL.md new file mode 100644 index 0000000..b9cc75c --- /dev/null +++ b/brmbar3/INSTALL.md @@ -0,0 +1,85 @@ +BrmBar v3 Installation +====================== + +This is woefully incomplete; if you are deploying BrmBar, at this +point you will likely need to learn at least something aobut its +structure and internals. Some code modifications might even be +required too. Patches enhancing user configurability are welcome! + +Maybe some things are missing. Ask the developers if you get in trouble, +e.g. in #brmlab on FreeNode. + +Hardware Requirements +--------------------- + +* Display. Current UI is optimized for 4:3, 1024x768 display. +* Touchscreen. In emergency, you can use a mouse too, but it's + clumsy and scrolling is not too intuitive. +* Barcode reader. We want the kind that will behave as a HID device + and on scanning a barcode, it will send a CR-terminated scanned string. +* Physical keyboard stashed in vicinity will help. It is possible + to enter text (inventory names, receipt reasons) on the touchscreen, + but it's a bit frustrating. +* You will want to print a sheet of barcodes with names of all user + accounts; these will be then used by people to buy stuff using their + accounts - first scan barcode of the item, then scan your barcode, + voila. Scanning your barcode directly can bring the user to a screen + where they can see their credit and charge it too. See also USAGE. + +Software Requirements +--------------------- + +* Developed and tested on Debian, but should work on other systems too. +* Python 3. +* QT4 with Python bindings: + * QT4 with the "Declarative" module, e.g. libqt4-declarative package. + * The PySide Qt4 bindings, e.g. python3-pyside.qtdeclarative package. + * Installing the qtcreator program may be helpful for QML testing + and development. +* PostgreSQL with Python pindings: + * The database server itself, e.g. postgresql package. + * PsyCoPg2, e.g. python3-psycopg2 package. + +Ubuntu packages installation instructions +----------------------------------------- +* sudo apt-get install postgresql libqt4-declarative python3 python3-pyside.qtdeclarative python3-psycopg2 python3-pyqt4 + +Software Setup +-------------- + +* Create psql user and `brmbar` database. + + brmuser@host:~> su postgres + postgres@host:/home/user> createuser -D brmuser + postgres@host:/home/user> su brmuser + brmuser@host:~> createdb brmbar + +* The SQL schema in file `SQL` contains the required SQL tables, + but also INSERTs that add some rows essential for proper operation; + base currency and two base accounts. You *will* want to tweak the + currency name; default is `Kč` (the Czech crown), replace it with + your currency symbol. Then do `git grep 'Kč'` and replace all other + occurences of `Kč` in brmbar source with your currency name. +* Load the SQL schema stored in file `SQL` in the database. + + brmuser@host:~/brmbar/brmbar3> psql brmbar + psql (9.1.8) + Type "help" for help. + + brmbar=# \i SQL + +* You should be able to fire up the GUI now and start entering data. + If you want to make sure all works as expected, execute the SQL + statements in file `SQL.test` (revisit for currency names too) which + will populate the database with a bit of sample data for testing. +* Regarding adding users at this point and for other usage instructions, + refer to the USAGE file. + +TODO: Mention the actual commands to execute. + +Troubleshooting +--------------- + +Assuming that you run brmbar from a terminal, if something gets +stuck, you can switch to the terminal by Alt-TAB, then kill brmbar +by the Ctrl-\ shortcut (sends SIGQUIT) and restart it. diff --git a/brmbar3/PURGE.txt b/brmbar3/PURGE.txt new file mode 100644 index 0000000..c5e5b30 --- /dev/null +++ b/brmbar3/PURGE.txt @@ -0,0 +1,64 @@ +How to "reset" the database - drop all history and keep only accounts with non-zero balance. + +Legend: +> - SQL commands +$ - shell commands + +Run the (full) inventory. + +Get number of the first inventory TX. + +> select id from account_balances where id in (select id from accounts where currency not in (select distinct currency from + transaction_nicesplits where transaction >= NUMBER_HERE and currency != 1 and memo like '%Inventory fix%') and acctype = 'inventory') and crbalance != 0 \g 'vynulovat' +$ ./brmbar-cli.py inventory `cat vynulovat | while read x; do echo $x 0; done` + +Backup the database +$ pg_dump brmbar > backup.sql + +Dump "> SELECT * FROM account_balances;" to file N. + +Dump inventory to file nastavit FIXME. + +Drop all transactions: +> delete from transaction_splits; +> delete from transactions; + +Restore inventory: +$ cat nastavit | while read acc p amt; do ./brmbar-cli.py inventory $acc `echo $amt | grep -oE "^[0-9-]+"`; done + +Restore cash balance: +$ cat N | grep debt | tr -s " " |cut -d \| -f 2,4 | while read acc p amt; do ./brmbar-cli.py changecredit $acc `echo $amt | grep -oE "^[0-9-]+"`; done + +Delete zero-balance accounts: +> delete from accounts where accounts.id not in (select id from account_balances); + +Delete orphaned barcodes: +> delete from barcodes where barcodes.account not in (select id from account_balances); + +Delete orphaned currencies and exchange rates: +> CREATE OR REPLACE VIEW "a_tmp" AS +SELECT ts.account AS id, accounts.name, accounts.acctype, accounts.currency AS fff, (- sum(CASE WHEN (ts.side = 'credit'::transaction_split_side) THEN (- ts.amount) ELSE ts.amount END)) AS crbalance FROM (transaction_splits ts LEFT JOIN accounts ON ((accounts.id = ts.account))) GROUP BY ts.account, accounts.name, accounts.id, accounts.acctype ORDER BY (- sum(CASE WHEN (ts.side = 'credit'::transaction_split_side) THEN (- ts.amount) ELSE ts.amount END)); + +> delete from exchange_rates where source not in (select fff from a_tmp); +> delete from currencies where id not in (select fff from a_tmp); + +> DROP VIEW "a_tmp"; + +Drop obsolete exchange rates: + +> delete from exchange_rates where + valid_since <> (SELECT max(valid_since) + FROM exchange_rates e + WHERE e.target = exchange_rates.target and e.source = exchange_rates.source) + +Restore system accounts: +> INSERT INTO "accounts" ("name", "currency", "acctype", "active") + VALUES ('BrmBar Profits', '1', 'income', '1'); +> INSERT INTO "accounts" ("name", "currency", "acctype", "active") + VALUES ('BrmBar Excess', '1', 'income', '1'); +> INSERT INTO "accounts" ("name", "currency", "acctype", "active") + VALUES ('BrmBar Deficit', '1', 'expense', '1'); +> INSERT INTO "accounts" ("name", "currency", "acctype", "active") + VALUES ('BrmBar Cash', '1', 'cash', '1'); + +Restart brmbar. diff --git a/brmbar3/README.md b/brmbar3/README.md new file mode 100644 index 0000000..b113083 --- /dev/null +++ b/brmbar3/README.md @@ -0,0 +1,81 @@ +BrmBar v3 +========= + +BrmBar is a management system for running a tiny hackerspace shop +with self-service usage based on trust and support for user accounts +and inventory tracking. + +BrmBar offers a touchscreen-based user interface, identifies items +and users by barcodes scanned by a barcode reader, should run on any +decent Linux machine and stores its data in PostgreSQL database. + +Features +-------- + +* Very simple user interface (using big touchscreen buttons and + barcode reader) that should enable even non-technical users to + do basic shopping with little to no training. +* Users may have their accounts they can load with money by + depositing larger sum of money in advance, then charging their + account when buying stuff. Of course, paying direct for cash + is also supported. +* Inventory and cash accounts are tracked so that you can make sure + there is no Club Mate mysteriously disappearing or if the amount + of cash in the cash box is not less than expected by the system. +* You can enter receipts for duct tapes and other necessities to be + financed by cash surplus generated by brmbar. +* Simple management operations (depositing and withdrawing money + from user accounts, entering receipts, stocking in new inventory) + can be also performed in the user interface even by non-technical + users with basic training. +* The database is based on the classical accounting paradigm. + This means no information is needlessly lost, you could even + make a GNUCash export and your accounting geeks will feel warm + and fuzzy. +* Multiple user interfaces available (and possible). The primary + user interface is based on QtQuick (Qt4 QML QtDeclarative). + +User Interfaces +--------------- + +These UIs are provided: + +* **brmbar-gui-qt4**: The default touchscreen-based UI. The Python side + provides an adapter object whose methods can be executed by the QML + code; ad-hoc directionary objects are used to exchange complex data + like account information. +* **brmbar-tui**: A trivial text-based "shell" UI that mimics a historic + interface used in the Brmlab hackerspace in the past. It supports only + selling items, querying item price and user account balance and + depositing money for the user accounts. +* **brmbar-cli**: A command-line interface intended for use in scripts + and remote usage when fixing problems. It is also meant to provide + advanced functionality like inventory revision that is too tedious + to implement in the Qt4 GUI and only the brmbar admins are expected + to do these tasks. +* **brmbar-web**: A simple read-only web interface to the stock list. + +TODO +---- + +* The user interface needs some improvements, mainly regarding + scrolling in large lists. +* The brmbar-cli.py admin script for advanced/remote management + operations is largely unfinished. In the meantime, you need to use + SQL statements, sorry. Or finish it yourself. :-) +* It is common to have two stashes of cash, one in a cash box + in the shop, another in a vault (sometimes called "overflow") + where extra cash is stored. The brmbar model supports this, + but UI support needs to be added. +* Bitcoin support, somehow... + +Some more TODO items may be listed in the GitHub issue tracker; +missing brmbar-gui-qt4 features are listed in the `TODO` file. + +Other Resources +--------------- + +See the INSTALL file for setup instructions and USAGE file for +basic usage instructions. The doc/architecture file describes +the brmbar object model and briefly explains the brmbar Python +package. diff --git a/brmbar3/SQL b/brmbar3/SQL index 1e012e9..04667f4 100644 --- a/brmbar3/SQL +++ b/brmbar3/SQL @@ -1,10 +1,11 @@ -CREATE SEQUENCE currencies_id_seq START WITH 1 INCREMENT BY 1; +CREATE SEQUENCE currencies_id_seq START WITH 2 INCREMENT BY 1; CREATE TABLE currencies ( id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('currencies_id_seq'::regclass), name VARCHAR(128) NOT NULL, UNIQUE(name) ); -INSERT INTO currencies (name) VALUES ('Kč'); +-- Some code depends on the primary physical currency to have id 1. +INSERT INTO currencies (id, name) VALUES (1, 'Kč'); CREATE TYPE exchange_rate_direction AS ENUM ('source_to_target', 'target_to_source'); CREATE TABLE exchange_rates ( @@ -36,10 +37,14 @@ CREATE TABLE accounts ( currency INTEGER NOT NULL, FOREIGN KEY (currency) REFERENCES currencies (id), - acctype account_type NOT NULL + acctype account_type NOT NULL, + + active BOOLEAN NOT NULL DEFAULT TRUE ); INSERT INTO accounts (name, currency, acctype) VALUES ('BrmBar Cash', (SELECT id FROM currencies WHERE name='Kč'), 'cash'); INSERT INTO accounts (name, currency, acctype) VALUES ('BrmBar Profits', (SELECT id FROM currencies WHERE name='Kč'), 'income'); +INSERT INTO accounts (name, currency, acctype) VALUES ('BrmBar Excess', (SELECT id FROM currencies WHERE name='Kč'), 'income'); +INSERT INTO accounts (name, currency, acctype) VALUES ('BrmBar Deficit', (SELECT id FROM currencies WHERE name='Kč'), 'expense'); CREATE SEQUENCE barcodes_id_seq START WITH 1 INCREMENT BY 1; @@ -49,6 +54,9 @@ CREATE TABLE barcodes ( account INTEGER NOT NULL, FOREIGN KEY (account) REFERENCES accounts (id) ); +-- Barcode for cash +-- XXX Silently assume there is only one. +INSERT INTO barcodes (barcode, account) VALUES ('_cash_', (SELECT id FROM accounts WHERE acctype = 'cash')); CREATE SEQUENCE transactions_id_seq START WITH 1 INCREMENT BY 1; @@ -79,3 +87,41 @@ CREATE TABLE transaction_splits ( memo TEXT ); + +-- List balances of accounts computed based on transactions +-- Note that currency information is currently not supplied; inventory items +-- have balances in stock amounts. +CREATE VIEW account_balances AS + SELECT ts.account AS id, accounts.name AS name, accounts.acctype AS acctype, + -SUM(CASE WHEN ts.side = 'credit' THEN -ts.amount ELSE ts.amount END) AS crbalance + FROM transaction_splits AS ts + LEFT JOIN accounts ON accounts.id = ts.account + GROUP BY ts.account, accounts.name, accounts.acctype + ORDER BY crbalance ASC; + +-- Transaction splits in a form that's nicer to query during manual inspection +CREATE VIEW transaction_nicesplits AS + SELECT ts.id AS id, ts.transaction AS transaction, ts.account AS account, + (CASE WHEN ts.side = 'credit' THEN -ts.amount ELSE ts.amount END) AS amount, + a.currency AS currency, ts.memo AS memo + FROM transaction_splits AS ts LEFT JOIN accounts AS a ON a.id = ts.account + ORDER BY ts.id; + +-- List transactions with summary information regarding their cash element. +CREATE VIEW transaction_cashsums AS + SELECT t.id AS id, t.time AS time, SUM(credit_cash) AS cash_credit, SUM(debit_cash) AS cash_debit, a.name AS responsible, t.description AS description + FROM transactions AS t + LEFT JOIN (SELECT cts.amount AS credit_cash, cts.transaction AS cts_t + FROM transaction_nicesplits AS cts + LEFT JOIN accounts AS a ON a.id = cts.account OR a.id = cts.account + WHERE a.currency = (SELECT currency FROM accounts WHERE name = 'BrmBar Cash') + AND a.acctype IN ('cash', 'debt') + AND cts.amount < 0) credit ON cts_t = t.id + LEFT JOIN (SELECT dts.amount AS debit_cash, dts.transaction AS dts_t + FROM transaction_nicesplits AS dts + LEFT JOIN accounts AS a ON a.id = dts.account OR a.id = dts.account + WHERE a.currency = (SELECT currency FROM accounts WHERE name = 'BrmBar Cash') + AND a.acctype IN ('cash', 'debt') + AND dts.amount > 0) debit ON dts_t = t.id + LEFT JOIN accounts AS a ON a.id = t.responsible + GROUP BY t.id, a.name ORDER BY t.id DESC; diff --git a/brmbar3/SQL-for-RO-access.sql b/brmbar3/SQL-for-RO-access.sql new file mode 100644 index 0000000..b8c7291 --- /dev/null +++ b/brmbar3/SQL-for-RO-access.sql @@ -0,0 +1,57 @@ +CREATE OR REPLACE FUNCTION accounts_id_seq_value() +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + result bigint; +BEGIN + SELECT last_value FROM accounts_id_seq + INTO result; + RETURN result; +END; +$$; + +CREATE OR REPLACE FUNCTION transactions_id_seq_value() +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + result bigint; +BEGIN + SELECT last_value FROM transactions_id_seq + INTO result; + RETURN result; +END; +$$; + +CREATE OR REPLACE FUNCTION transaction_splits_id_seq_value() +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + result bigint; +BEGIN + SELECT last_value FROM transaction_splits_id_seq + INTO result; + RETURN result; +END; +$$; + +CREATE OR REPLACE FUNCTION currencies_id_seq_value() +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + result bigint; +BEGIN + SELECT last_value FROM currencies_id_seq + INTO result; + RETURN result; +END; +$$; + + diff --git a/brmbar3/SQL-schema-v001.sql b/brmbar3/SQL-schema-v001.sql new file mode 100644 index 0000000..44b9693 --- /dev/null +++ b/brmbar3/SQL-schema-v001.sql @@ -0,0 +1,59 @@ +--RESET search_path; +SELECT pg_catalog.set_config('search_path', '', false); +-- intoduce implementation schema +CREATE SCHEMA IF NOT EXISTS brmbar_implementation; +-- version table (with initialization) +CREATE TABLE IF NOT EXISTS brmbar_implementation.brmbar_schema ( + ver INTEGER NOT NULL +); +DO $$ +DECLARE v INTEGER; +BEGIN + SELECT ver FROM brmbar_implementation.brmbar_schema INTO v; + IF v IS NULL THEN + INSERT INTO brmbar_implementation.brmbar_schema (ver) VALUES (1); + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION brmbar_implementation.has_exact_schema_version( + IN i_ver INTEGER NOT NULL +) RETURNS INTEGER +VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $$ +DECLARE + v_ver INTEGER; +BEGIN + SELECT ver INTO STRICT v_ver FROM brmbar_implementation.brmbar_schema; + IF v_ver IS NULL or v_ver <> i_ver THEN + RAISE EXCEPTION 'Invalid brmbar schema version'; + END IF; + RETURN v_ver; +/* +EXCEPTION + WHEN NO_DATA_FOUND THEN + RAISE EXCEPTION 'PID % not found'; + WHEN TOO_MANY_ROWS THEN + RAISE EXCEPTION 'PID % not unique'; +*/ +END; +$$; + +CREATE OR REPLACE FUNCTION brmbar_implementation.upgrade_schema_version_to( + IN i_ver INTEGER NOT NULL +) RETURNS INTEGER +VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $$ +DECLARE + v_ver INTEGER; +BEGIN + SELECT brmbar_implementation.has_exact_schema_version(i_ver) INTO v_ver; + IF v_ver + 1 = i_ver THEN + UPDATE brmbar_implementation.brmbar_schema SET ver = i_ver; + ELSE + RAISE EXCEPTION 'Invalid brmbar schema version'; + END IF; + RETURN i_ver; +END; +$$; + +-- vim: set ft=plsql : + diff --git a/brmbar3/SQL-schema-v002.sql b/brmbar3/SQL-schema-v002.sql new file mode 100644 index 0000000..bfb63fa --- /dev/null +++ b/brmbar3/SQL-schema-v002.sql @@ -0,0 +1,61 @@ +--RESET search_path +SELECT pg_catalog.set_config('search_path', '', false); + +--- upgrade schema +DO $upgrade_block$ +DECLARE +current_ver INTEGER; +BEGIN + +-- confirm that we are upgrading from version 1 +SELECT brmbar_implementation.has_exact_schema_version(1) INTO current_ver; +IF current_ver <> 1 THEN + RAISE EXCEPTION 'BrmBar schema version % cannot be upgraded to version 2.', current_ver; +END IF; + +-- structural changes + +-- TRADING ACCOUNTS +--START TRANSACTION ISOLATION LEVEL SERIALIZABLE; + +-- currency trading accounts - account type +ALTER TYPE public.account_type ADD VALUE IF NOT EXISTS 'trading'; + +-- constraint needed for foreign key in currencies table +ALTER TABLE public.accounts ADD CONSTRAINT accounts_id_acctype_key UNIQUE(id, acctype); + +-- add columns to currencies to record the trading account associated with the currency +ALTER TABLE public.currencies + ADD COLUMN IF NOT EXISTS trading_account integer, + ADD COLUMN IF NOT EXISTS trading_account_type account_type GENERATED ALWAYS AS ('trading'::public.account_type) STORED; + +-- make trading accounts (without making duplicates) +INSERT INTO public.accounts ("name", "currency", acctype) +SELECT + 'Currency Trading Account: ' || c."name", + c.id, + 'trading'::public.account_type +FROM public.currencies AS c +WHERE NOT EXISTS ( + SELECT 1 + FROM public.accounts a + WHERE a.currency = c.id AND a.acctype = 'trading'::public.account_type +); + + +-- record the trading account IDs in currencies table +UPDATE public.currencies AS c SET (trading_account) = (SELECT a.id FROM public.accounts AS a WHERE a.currency = c.id AND c.acctype = 'trading'::public.account_type); + +-- foreign key to check the validity of currency trading account reference +ALTER TABLE public.currencies + ADD CONSTRAINT currencies_trading_fkey FOREIGN KEY (trading_account, trading_account_type) + REFERENCES xaccounts(id,acctype) DEFERRABLE INITIALLY DEFERRED; + +--COMMIT AND CHAIN; + +SELECT brmbar_implementation.upgrade_schema_version_to(2) INTO current_ver; +-- end of upgrade do block +end +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/TODO b/brmbar3/TODO index 5afc2de..edfcc27 100644 --- a/brmbar3/TODO +++ b/brmbar3/TODO @@ -1,26 +1,5 @@ -+ Management view - + Stock management link - + User management link - + Bilance overview (Cash, Profit, Credit total) -+ Item picker from list -+ User management - + List of users - + Withdrawal of user credit -+ Numerical manual entry support - + Use for credit charge - + Use for withdrawal -+ Restocking view (Stock management) - + Item picker with edit button - + Item editor (name, buy price, sale price, quantity) - + Item-barcode assignment - + Support for adding new items -+ Alphanumeric manual entry support - + Use in item editor -+ Withdrawal for brmbar receipts - 1. User responsible - 2. Amount and description - -** At this point it should be good enough to deploy ** +This reprensents some generic features that would need to be implemented +in brmbar-gui-qt4 to have a fully fledged user interface. . User management - Add user diff --git a/brmbar3/USAGE.md b/brmbar3/USAGE.md new file mode 100644 index 0000000..d749a3a --- /dev/null +++ b/brmbar3/USAGE.md @@ -0,0 +1,153 @@ +Quick Guide +----------- + +* I want to buy for cash: I scan item's barcode, press **Pay by Cash** and pour + money into the cash box. + +* I want to buy from credit: I scan item's barcode, then my barcode. +(If you don't have your barcode printed out, you can also type your +username on a physical keyboard.) + +* I want to put money on credit: press **Charge**, I scan my barcode, +type some amount, press **Charge** and put money in the cash box. + + +Advanced Operations +------------------- + +* I want to withdraw funds from my (positive) credit: +Press **Management**, choose **User Mgmt**, scan your barcode, +press the Withdraw Amount and type the amount. Then take the money +from the cash box. + +* I want to stock in some inventory (that's been in brmbar before): +Press **Management**, **Stock Mgmt**, scan barcode of the item, edit +the purchase price (or also the selling price and label), press +**Restock** and enter the quantity of stocked in piece. Press **Save**. +Toss the bill (if possible with the current written date, to allow +pairing) to brmbar. + +* I want to stock in some new inventory: Press **Management**, **Stock +Mgmt**, press **Add new item**, enter the name, purchase and selling price, +press **Create**. Then press **Restock**, enter the quantity stocked in. +Scan the item's barcode and press **Save**. Toss the bill in brmbar. + +* I want to bill the brmbar with some small expenses like duct tape: +Press **Management** and **Receipt**. Press **Description** and write +a brief description of the bill. Press **Edit** near the **Money Amount** +and enter the amount. Scan *your* barcode. The operation is finished +by pressing **Create**. Toss bill (inscribed with the current date +to ease pairing) to brmbar. + + +General Notes +------------- + +The system expects that we take money from the cash box right away. +If you don't want to (or there is e.g. not enough money), put money +on your credit account instead (see above). Please always do that +(never *I'll remember and I'll take money later*) so that there is +a record that the cash box and system records are not in sync and +there are no irregularities. + +To enter text (or numbers too), you can use both the on-screen keyboard +and the physical keyboard nearby. + + +Administrative Usage +-------------------- + +* The most common administrative action you will need to do is adding + new user (also called debt or credit) accounts. The GUI support for + this is not implemented yet, but the `brmbar-cli.py` UI allows it: + + ./brmbar-cli.py adduser joehacker + + Afterwards, print out a barcode saying "joehacker" and stick that + somewhere nearby; scanning that barcode will allow access to this + account (and so will typing "joehacker" on a physical keyboard). + +* If your inventory stock count or cash box amount does not match + the in-system data, you will need to make a corrective transaction. + To fix cash amount to reality in which you counted 1234Kč, use + + ./brmbar-cli.py fixcash 1234 + + whereas to fix amount of a particular stock, use + + ./brmbar-cli.py inventory-interactive + + then scan the item barcode and then enter the right amount. + +* If you want to view recent transactions, run + + psql brmbar + select * from transaction_cashsums; + +* If you want to undo a transaction, get its id (using the select above) + and run + + ./brmbar-cli.py undo ID + +* If you want to get overview of the financial situation, run + + ./brmbar-cli.py stats + + The following items represent "material", "tangible" assets: + + * Cash - how much should be in the money box + * Overflow - how much cash is stored in overflow credit accounts (pockets of admins) + * Inventory - how much worth (buy price) is the current inventory stock + + I.e., cash plus overflow plus inventory is how much brmbar is worth + and cash plus overflow is how much brmbar can spend right now. + + The following items represent "virtual" accounts which determine + the logical composition of the assets: + + * Credit - sum of all credit accounts, i.e. money stored in brmbar by its users; + i.e. how much of the assets is users' money + * Profit - accumulated profit made by brmbar on buy/sell margins (but receipts + and inventory deficits are subtracted); i.e. how much of the assets is brmbar's + own money + * Fixups - sum of gains and losses accrued by inventory fixups, i.e. stemming + from differences between accounting and reality - positive is good, negative + is bad; this amount is added to profit on consolidation + + The total worth of the material and virtual accounts should be equal. + + +Useful SQL queries +------------------ + +* Compute sum of sold stock: + + select sum(amount) from transactions + left join transaction_splits on transaction_splits.transaction = transactions.id + where description like '% sale %' and side = 'debit'; + +* List of items not covered by inventory check: + + select * from account_balances + where id not in (select account from transactions + left join transaction_splits on transaction_splits.transaction = transactions.id + where description like '% inventory %') + and acctype = 'inventory'; + +* List all cash transactions: + + select time, transactions.id, description, responsible, amount from transactions + left join transaction_splits on transaction_splits.transaction = transactions.id + where transaction_splits.account = 1; + +* List all inventory items ordered by their cummulative worth: + + select foo.*, foo.rate * -foo.crbalance as worth from + (select account_balances.*, + (select exchange_rates.rate from exchange_rates, accounts + where exchange_rates.target = accounts.currency + and accounts.id = account_balances.id + order by exchange_rates.valid_since limit 1) as rate + from account_balances where account_balances.acctype = 'inventory') + as foo order by worth; + diff --git a/brmbar3/USEFUL.txt b/brmbar3/USEFUL.txt new file mode 100644 index 0000000..11c0071 --- /dev/null +++ b/brmbar3/USEFUL.txt @@ -0,0 +1,8 @@ +Accounts with multiple barcodes: + +SELECT accounts.name,barcodes.account,barcodes.barcode +FROM "barcodes" +join accounts on accounts.id = barcodes.account +where barcodes.account in (select a from (select count(*) as c, account as a from barcodes group by account) as dt where c > 1) +ORDER BY "account" DESC + diff --git a/brmbar3/alert.sh b/brmbar3/alert.sh new file mode 100755 index 0000000..05496e6 --- /dev/null +++ b/brmbar3/alert.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +case $1 in +alert) mplayer -really-quiet ~/trombone.wav & ;; +limit) mplayer -really-quiet ~/much.wav & ;; +charge) mplayer -really-quiet ~/charge.wav & ;; +esac diff --git a/brmbar3/autostock.py b/brmbar3/autostock.py new file mode 100755 index 0000000..0e8afac --- /dev/null +++ b/brmbar3/autostock.py @@ -0,0 +1,42 @@ +#! /usr/bin/env python3 + +import argparse +import brmbar +import math +from brmbar import Database + +def main(): + parser = argparse.ArgumentParser(usage = "File format: EAN amount total_price name, e.g. 4001242002377 6 167.40 Chio Tortillas") + parser.add_argument("filename") + args = parser.parse_args() + + db = Database.Database("dbname=brmbar") + shop = brmbar.Shop.new_with_defaults(db) + currency = shop.currency + + # ... + total = 0 + with open(args.filename) as fin: + for line in fin: + split = line.split(" ") + ean, amount, price_total, name = split[0], int(split[1]), float(split[2]), " ".join(split[3:]) + name = name.strip() + + price_buy = price_total / amount + acct = brmbar.Account.load_by_barcode(db, ean) + if not acct: + print("Creating account for EAN {} '{}'".format(ean, name)) + invcurr = brmbar.Currency.create(db, name) + acct = brmbar.Account.create(db, name, invcurr, "inventory") + acct.add_barcode(ean) + price_sell = max(math.ceil(price_buy * 1.15), price_buy) + acct.currency.update_sell_rate(currency, price_sell) + acct.currency.update_buy_rate(currency, price_buy) + cash = shop.buy_for_cash(acct, amount) + total += cash + print("Increased by {}, take {} from cashbox".format(amount, cash)) + print("Total is {}".format(total)) + +if __name__ == "__main__": + main() + diff --git a/brmbar3/brmbar-cli.py b/brmbar3/brmbar-cli.py index 4e75325..cc5c6e3 100755 --- a/brmbar3/brmbar-cli.py +++ b/brmbar3/brmbar-cli.py @@ -1,61 +1,266 @@ #!/usr/bin/python3 import sys -import psycopg2 + +from brmbar import Database import brmbar -db = psycopg2.connect("dbname=brmbar") + +def help(): + print("""BrmBar v3 (c) Petr Baudis 2012-2013 + +Usage: brmbar-cli.py COMMAND ARGS... + +1. Commands pertaining the standard operation + showcredit USER + changecredit USER +-AMT + sellitem {USER|"cash"} ITEM +-AMT + You can use negative AMT to undo a sale. + restock ITEM AMT + userinfo USER + userlog USER TIMESTAMP + iteminfo ITEM + +2. Management commands + listusers + List all user accounts in the system. + listitems + List all item accounts in the system. + stats + A set of various balances as shown in the Management + screen of the GUI. + adduser USER + Add user (debt) account with given username. + undo TRANSID + Commit a transaction that reverses all splits of a transaction with + a given id (to find out that id: select * from transaction_cashsums;) + +3. Inventorization + + inventory ITEM1 NEW_AMOUNT1 ITEM2 NEW_AMOUNT2 + Inventory recounting (fixing the number of items) + inventory-interactive + Launches interactive mode for performing inventory with barcode reader + fixcash AMT + Fixes the cash and puts money difference into excess or deficit account + consolidate + Wraps up inventory + cash recounting, transferring the excess and + deficit accounts balance to the profits account and resetting them + +USER and ITEM may be barcodes or account ids. AMT may be +both positive and negative amount (big difference to other +user interfaces; you can e.g. undo a sale!). + +For users, you can use their name as USER as their username +is also the barcode. For items, use listitems command first +to find out the item id. + +EXAMPLES: + +Transfer 35Kc from pasky to sachy: + + $ ./brmbar-cli.py changecredit pasky -35 + $ ./brmbar-cli.py changecredit sachy +35 + +Buy one RaspberryPi for cash from commandline: + + $ ./brmbar-cli.py listitems | grep -i raspberry + Raspberry Pi 2 1277 1.00 pcs + $ ./brmbar-cli.py sellitem cash 1277 1 +""") + sys.exit(1) + + +def load_acct(inp): + acct = None + if inp.isdigit(): + acct = brmbar.Account.load(db, id = inp) + if acct is None: + acct = brmbar.Account.load_by_barcode(db, inp) + if acct is None: + print("Cannot map account " + inp, file=sys.stderr) + exit(1) + return acct + +def load_user(inp): + acct = load_acct(inp) + if acct.acctype != "debt": + print("Bad account " + inp + " type " + acct.acctype, file=sys.stderr) + exit(1) + return acct + +def load_item(inp): + acct = load_acct(inp) + if acct.acctype != "inventory": + print("Bad account " + inp + " type " + acct.acctype, file=sys.stderr) + exit(1) + return acct + +def load_item_by_barcode(inp): + acct = brmbar.Account.load_by_barcode(db, inp) + if acct.acctype != "inventory": + print("Bad EAN " + inp + " type " + acct.acctype, file=sys.stderr) + exit(1) + return acct + +db = Database.Database("dbname=brmbar") shop = brmbar.Shop.new_with_defaults(db) currency = shop.currency -active_inv_item = None -active_credit = None +if len(sys.argv) <= 1: + help() -for line in sys.stdin: - barcode = line.rstrip() - if barcode[0] == "$": - credits = {'$02': 20, '$05': 50, '$10': 100, '$20': 200, '$50': 500, '$1k': 1000} - credit = credits[barcode] - if credit is None: - print("Unknown barcode: " + barcode) - continue - print("CREDIT " + str(credit)) - active_inv_item = None - active_credit = credit - continue +if sys.argv[1] == "showcredit": + acct = load_user(sys.argv[2]) + print("{}: {}".format(acct.name, acct.negbalance_str())) - if barcode == "SCR": - print("SHOW CREDIT") - active_inv_item = None - active_credit = None - continue +elif sys.argv[1] == "changecredit": + acct = load_user(sys.argv[2]) + amt = int(sys.argv[3]) + if amt > 0: + shop.add_credit(credit = amt, user = acct) + elif amt < 0: + shop.withdraw_credit(credit = -amt, user = acct) + print("{}: {}".format(acct.name, acct.negbalance_str())) - acct = brmbar.Account.load_by_barcode(db, barcode) - if acct is None: - print("Unknown barcode: " + barcode) - continue - - if acct.acctype == 'debt': - if active_inv_item is not None: - cost = shop.sell(item = active_inv_item, user = acct) - print("{} has bought {} for {} and now has {} balance".format(acct.name, active_inv_item.name, currency.str(cost), acct.negbalance_str())) - elif active_credit is not None: - shop.add_credit(credit = active_credit, user = acct) - print("{} has added {} credit and now has {} balance".format(acct.name, currency.str(active_credit), acct.negbalance_str())) - else: - print("{} has {} balance".format(acct.name, acct.negbalance_str())) - active_inv_item = None - active_credit = None +elif sys.argv[1] == "sellitem": + if sys.argv[2] == "cash": + uacct = shop.cash + else: + uacct = load_user(sys.argv[2]) + iacct = load_item(sys.argv[3]) + amt = int(sys.argv[4]) + if amt > 0: + if uacct == shop.cash: + shop.sell_for_cash(item = iacct, amount = amt) + else: + shop.sell(item = iacct, user = uacct, amount = amt) + elif amt < 0: + shop.undo_sale(item = iacct, user = uacct, amount = -amt) + print("{}: {}".format(uacct.name, uacct.balance_str() if uacct == shop.cash else uacct.negbalance_str())) + print("{}: {}".format(iacct.name, iacct.balance_str())) - elif acct.acctype == 'inventory': - buy, sell = acct.currency.rates(currency) - print("{} costs {} with {} in stock".format(acct.name, currency.str(sell), int(acct.balance()))) - active_inv_item = acct - active_credit = None +elif sys.argv[1] == "userinfo": + acct = load_user(sys.argv[2]) + print("{} (id {}): {}".format(acct.name, acct.id, acct.negbalance_str())) - else: - print("invalid account type {}".format(acct.acctype)) - active_inv_item = None - active_credit = None + res = db.execute_and_fetchall("SELECT barcode FROM barcodes WHERE account = %s", [acct.id]) + print("Barcodes: " + ", ".join(map((lambda r: r[0]), res))) + +elif sys.argv[1] == "userlog": + acct = load_user(sys.argv[2]) + timestamp = sys.argv[3] + + res = db.execute_and_fetchall("SELECT * FROM transaction_cashsums WHERE responsible=%s and time > TIMESTAMP %s ORDER BY time", [acct.name,timestamp]) + for transaction in res: + print('\t'.join([str(f) for f in transaction])) + +elif sys.argv[1] == "iteminfo": + acct = load_item(sys.argv[2]) + print("{} (id {}): {} pcs".format(acct.name, acct.id, acct.balance())) + + (buy, sell) = acct.currency.rates(currency) + print("Buy: " + currency.str(buy) + " Sell: " + currency.str(sell)); + + res = db.execute_and_fetchall("SELECT barcode FROM barcodes WHERE account = %s", [acct.id]) + print("Barcodes: " + ", ".join(map((lambda r: r[0]), res))) + +elif sys.argv[1] == "listusers": + for acct in shop.account_list("debt"): + print("{}\t{}\t{}".format(acct.name, acct.id, acct.negbalance_str())) + +elif sys.argv[1] == "listitems": + for acct in shop.account_list("inventory"): + print("{}\t{}\t{} pcs".format(acct.name, acct.id, acct.balance())) + +elif sys.argv[1] == "stats": + print("--- Material Assets ---") + print("Cash: {}".format(shop.cash.balance_str())) + print("Overflow: {}".format(shop.currency.str(shop.credit_balance(overflow='only')))) + print("Inventory: {}".format(shop.inventory_balance_str())) + print("--- Logical Accounts ---") + print("Credit: {}".format(shop.credit_negbalance_str(overflow='exclude'))) + print("Profit: {}".format(shop.profits.balance_str())) + print("Fixups: {} (excess {}, deficit {})".format( + -shop.excess.balance() - shop.deficit.balance(), + shop.excess.negbalance_str(), + shop.deficit.balance_str())) + +elif sys.argv[1] == "adduser": + acct = brmbar.Account.create(db, sys.argv[2], brmbar.Currency.load(db, id = 1), 'debt') + acct.add_barcode(sys.argv[2]) # will commit + print("{}: id {}".format(acct.name, acct.id)); + +elif sys.argv[1] == "undo": + newtid = shop.undo(int(sys.argv[2])) + print("Transaction %d undone by reverse transaction %d" % (int(sys.argv[2]), newtid)) + +elif sys.argv[1] == "inventory": + if (len(sys.argv) % 2 != 0 or len(sys.argv) < 4): + print ("Invalid number of parameters, count your parameters.") + else: + for i in range(2, len(sys.argv), 2): + iacct = load_item(sys.argv[i]) + iamt = int(sys.argv[i+1]) + print("Current state {} (id {}): {} pcs".format(iacct.name, iacct.id, iacct.balance())) + if shop.fix_inventory(item = iacct, amount = iamt): + print("New state {} (id {}): {} pcs".format(iacct.name, iacct.id, iacct.balance())) + else: + print ("No action needed amount is correct.") + + +elif sys.argv[1] == "inventory-interactive": + print("Inventory interactive mode. To exit interactive mode just enter empty barcode") + + while True: + barcode = str(input("Enter barcode:")) + fuckyou = input("fuckyou") + if barcode == "": + break + iacct = brmbar.Account.load_by_barcode(db, barcode) + amount = str(input("What is the amount of {} in reality (expected: {} pcs):".format(iacct.name, iacct.balance()))) + if amount == "": + break + elif int(amount) > 10000: + print("Ignoring too high amount {}, assuming barcode was mistakenly scanned instead".format(amount)) + else: + iamt = int(amount) + print("Current state {} (id {}): {} pcs".format(iacct.name, iacct.id, iacct.balance())) + if shop.fix_inventory(item = iacct, amount = iamt): + print("New state {} (id {}): {} pcs".format(iacct.name, iacct.id, iacct.balance())) + else: + print("No action needed, amount is correct.") + print("End of processing. Bye") + +elif sys.argv[1] == "fixcash" or sys.argv[1] == "changecash": + if (len(sys.argv) != 3): + print ("Invalid number of parameters, check your parameters.") + else: + print("Current Cash is : {}".format(shop.cash.balance_str())) + iamt = int(sys.argv[2]) + if shop.fix_cash(amount = iamt): + print("New Cash is : {}".format(shop.cash.balance_str())) + else: + print ("No action needed amount is the same.") + +elif sys.argv[1] == "consolidate": + if (len(sys.argv) != 2): + print ("Invalid number of parameters, check your parameters.") + else: + shop.consolidate() + +elif sys.argv[1] in {"restock", "restock_ean"}: + if (len(sys.argv) != 4): + print ("Invalid number of parameters, check your parameters.") + else: + iacct = (load_item if sys.argv[1] == "restock" else load_item_by_barcode)(sys.argv[2]) + oldbal = iacct.balance() + amt = int(sys.argv[3]) + cash = shop.buy_for_cash(iacct, amt); + print("Old amount {}, increased by {}, take {} from cashbox".format(oldbal, amt, cash)) + + +else: + help() diff --git a/brmbar3/brmbar-gui-qt4.py b/brmbar3/brmbar-gui-qt4.py index 36af3f7..9ca11d0 100755 --- a/brmbar3/brmbar-gui-qt4.py +++ b/brmbar3/brmbar-gui-qt4.py @@ -1,154 +1,233 @@ #!/usr/bin/python3 import sys -import psycopg2 +import subprocess from PySide import QtCore, QtGui, QtDeclarative +from brmbar import Database + import brmbar +# User credit balance limit; sale will fail when balance is below this limit. +LIMIT_BALANCE = -200 +# When below this credit balance, an alert hook script (see below) is run. +ALERT_BALANCE = 0 +# This script is executed when a user is buying things and their balance is +# below LIMIT_BALANCE (with argument "limit") or below ALERT_BALANCE +# (with argument "alert"). +ALERT_SCRIPT = "./alert.sh" + class ShopAdapter(QtCore.QObject): - """ Interface between QML and the brmbar package """ - def __init__(self): - QtCore.QObject.__init__(self) + """ Interface between QML and the brmbar package """ + def __init__(self): + QtCore.QObject.__init__(self) - def acct_debt_map(self, acct): - map = acct.__dict__.copy() - map["balance"] = str(acct.balance()) - map["negbalance"] = str(-acct.balance()) - map["negbalance_str"] = acct.negbalance_str() - return map + def acct_debt_map(self, acct): + map = acct.__dict__.copy() + map["balance"] = str(acct.balance()) + map["negbalance"] = str(-acct.balance()) + map["negbalance_str"] = acct.negbalance_str() + return map - def acct_inventory_map(self, acct): - buy, sell = acct.currency.rates(currency) - map = acct.__dict__.copy() - map["balance"] = "{:.0f}".format(acct.balance()) - map["buy_price"] = str(buy) - map["price"] = str(sell) - return map + def acct_inventory_map(self, acct): + buy, sell = acct.currency.rates(currency) + map = acct.__dict__.copy() + map["balance"] = "{:.0f}".format(acct.balance()) + map["buy_price"] = str(buy) + map["price"] = str(sell) + return map - def acct_map(self, acct): - if acct is None: - return None - if acct.acctype == 'debt': - return self.acct_debt_map(acct) - elif acct.acctype == "inventory": - return self.acct_inventory_map(acct) - else: - return None + def acct_inventory_map2(self, acct): + buy, sell = 666, 666 + map = acct.__dict__.copy() + map["balance"] = "{:.0f}".format(666) + map["buy_price"] = str(buy) + map["price"] = str(sell) + return map - @QtCore.Slot(str, result='QVariant') - def barcodeInput(self, barcode): - """ Evaluate barcode received on input + def acct_cash_map(self, acct): + map = acct.__dict__.copy() + return map - Normally, we would return just the account object, but - passing that to QML appears to be very non-trivial. - Therefore, we construct a map that we can pass around easily. - We return None on unrecognized barcode. """ - barcode = str(barcode) - if barcode and barcode[0] == "$": - credits = {'$02': 20, '$05': 50, '$10': 100, '$20': 200, '$50': 500, '$1k': 1000} - credit = credits[barcode] - if credit is None: - return None - return { "acctype": "recharge", "amount": str(credit)+".00" } - return self.acct_map(brmbar.Account.load_by_barcode(db, barcode)) + def acct_map(self, acct): + if acct is None: + return None + if acct.acctype == 'debt': + return self.acct_debt_map(acct) + elif acct.acctype == "inventory": + return self.acct_inventory_map(acct) + elif acct.acctype == "cash": + return self.acct_cash_map(acct) + else: + return None - @QtCore.Slot('QVariant', result='QVariant') - def loadAccount(self, dbid): - return self.acct_map(brmbar.Account.load(db, id = dbid)) + @QtCore.Slot(str, result='QVariant') + def barcodeInput(self, barcode): + """ Evaluate barcode received on input - @QtCore.Slot('QVariant', 'QVariant', result='QVariant') - def sellItem(self, itemid, userid): - user = brmbar.Account.load(db, id = userid) - shop.sell(item = brmbar.Account.load(db, id = itemid), user = user) - return user.negbalance_str() + Normally, we would return just the account object, but + passing that to QML appears to be very non-trivial. + Therefore, we construct a map that we can pass around easily. + We return None on unrecognized barcode. """ + barcode = str(barcode) + if barcode and barcode[0] == "$": + credits = {'$02': 20, '$05': 50, '$10': 100, '$20': 200, '$50': 500, '$1k': 1000} + credit = credits[barcode] + if credit is None: + return None + return { "acctype": "recharge", "amount": str(credit)+".00" } + acct = self.acct_map(brmbar.Account.load_by_barcode(db, barcode)) + db.commit() + return acct - @QtCore.Slot('QVariant', result='QVariant') - def sellItemCash(self, itemid): - shop.sell_for_cash(item = brmbar.Account.load(db, id = itemid)) + @QtCore.Slot('QVariant', result='QVariant') + def loadAccount(self, dbid): + acct = self.acct_map(brmbar.Account.load(db, id = dbid)) + db.commit() + return acct - @QtCore.Slot('QVariant', 'QVariant', result='QVariant') - def chargeCredit(self, credit, userid): - user = brmbar.Account.load(db, id = userid) - shop.add_credit(credit = credit, user = user) - return user.negbalance_str() + @QtCore.Slot('QVariant', 'QVariant', result='QVariant') + def canSellItem(self, itemid, userid): + user = brmbar.Account.load(db, id = userid) + if -user.balance() > ALERT_BALANCE: + return True + elif -user.balance() > LIMIT_BALANCE: + subprocess.call(["sh", ALERT_SCRIPT, "alert"]) + return True + else: + subprocess.call(["sh", ALERT_SCRIPT, "limit"]) + return False - @QtCore.Slot('QVariant', 'QVariant', result='QVariant') - def withdrawCredit(self, credit, userid): - user = brmbar.Account.load(db, id = userid) - shop.withdraw_credit(credit = credit, user = user) - return user.negbalance_str() + @QtCore.Slot('QVariant', 'QVariant', result='QVariant') + def sellItem(self, itemid, userid): + user = brmbar.Account.load(db, id = userid) + shop.sell(item = brmbar.Account.load(db, id = itemid), user = user) + balance = user.negbalance_str() + db.commit() + return balance - @QtCore.Slot(result='QVariant') - def balance_cash(self): - return shop.cash.balance_str() - @QtCore.Slot(result='QVariant') - def balance_profit(self): - return shop.profits.balance_str() - @QtCore.Slot(result='QVariant') - def balance_inventory(self): - return shop.inventory_balance_str() - @QtCore.Slot(result='QVariant') - def balance_credit(self): - return shop.credit_negbalance_str() + @QtCore.Slot('QVariant', result='QVariant') + def sellItemCash(self, itemid): + shop.sell_for_cash(item = brmbar.Account.load(db, id = itemid)) + db.commit() - @QtCore.Slot(result='QVariant') - def userList(self): - return [ self.acct_debt_map(a) for a in shop.account_list("debt") ] + @QtCore.Slot('QVariant', 'QVariant', result='QVariant') + def chargeCredit(self, credit, userid): + subprocess.call(["sh", ALERT_SCRIPT, "charge"]) + user = brmbar.Account.load(db, id = userid) + shop.add_credit(credit = credit, user = user) + balance = user.negbalance_str() + db.commit() + return balance - @QtCore.Slot(result='QVariant') - def itemList(self): - return [ self.acct_inventory_map(a) for a in shop.account_list("inventory") ] + @QtCore.Slot('QVariant', 'QVariant', result='QVariant') + def withdrawCredit(self, credit, userid): + user = brmbar.Account.load(db, id = userid) + shop.withdraw_credit(credit = credit, user = user) + balance = user.negbalance_str() + db.commit() + return balance - @QtCore.Slot('QVariant', 'QVariant', result='QVariant') - def addBarcode(self, dbid, barcode): - return brmbar.Account.load(db, id = dbid).add_barcode(barcode) + @QtCore.Slot('QVariant', 'QVariant', 'QVariant', result='QVariant') + def newTransfer(self, uidfrom, uidto, amount): + ufrom = brmbar.Account.load(db, id=uidfrom) + uto = brmbar.Account.load(db, id=uidto) + shop.transfer_credit(ufrom, uto, amount = amount) + db.commit() + return currency.str(float(amount)) - @QtCore.Slot('QVariant', 'QVariant', result='QVariant') - def saveItem(self, dbid, invmap): - acct = brmbar.Account.load(db, id = dbid) - if (acct.name != invmap["name"]): - acct.rename(invmap["name"]) - buy, sell = acct.currency.rates(currency) - if (sell != invmap["price"]): - acct.currency.update_sell_rate(currency, invmap["price"]) - if (buy != invmap["buy_price"]): - acct.currency.update_buy_rate(currency, invmap["buy_price"]) - cost = "" - if (acct.balance() < int(invmap["balance"])): - cost = shop.buy_for_cash(acct, invmap["balance"] - acct.balance()) - else: - db.commit() - return { "dbid": dbid, "cost": (currency.str(cost) if cost != "" else "") } + @QtCore.Slot('QVariant', result='QVariant') + def balance_user(self, userid): + user = brmbar.Account.load(db, id=userid) + return user.negbalance_str() - @QtCore.Slot('QVariant', result='QVariant') - def newItem(self, invmap): - if (invmap["name"] == "" or invmap["price"] == "" or invmap["buy_price"] == ""): - return None - invcurrency = brmbar.Currency.create(db, invmap["name"]) - invcurrency.update_sell_rate(currency, invmap["price"]) - invcurrency.update_buy_rate(currency, invmap["buy_price"]) - acct = brmbar.Account.create(db, invmap["name"], invcurrency, "inventory") - cost = "" - if (int(invmap["balance"]) > 0): - cost = shop.buy_for_cash(acct, invmap["balance"]) # implicit db.commit() - else: - db.commit() - return { "dbid": acct.id, "cost": (currency.str(cost) if cost != "" else "") } + @QtCore.Slot(result='QVariant') + def balance_cash(self): + balance = shop.cash.balance_str() + db.commit() + return balance + @QtCore.Slot(result='QVariant') + def balance_profit(self): + balance = shop.profits.balance_str() + db.commit() + return balance + @QtCore.Slot(result='QVariant') + def balance_inventory(self): + balance = shop.inventory_balance_str() + db.commit() + return balance + @QtCore.Slot(result='QVariant') + def balance_credit(self): + balance = shop.credit_negbalance_str() + db.commit() + return balance - @QtCore.Slot('QVariant', 'QVariant', 'QVariant', result='QVariant') - def newReceipt(self, userid, description, amount): - if (description == "" or amount == ""): - return None - user = brmbar.Account.load(db, id = userid) - shop.receipt_to_credit(user, amount, description) - return user.negbalance_str() + @QtCore.Slot(result='QVariant') + def userList(self): + alist = [ self.acct_debt_map(a) for a in shop.account_list("debt") ] + db.commit() + return alist -db = psycopg2.connect("dbname=brmbar") + @QtCore.Slot('QVariant', result='QVariant') + def itemList(self, query): + alist = [ self.acct_inventory_map2(a) for a in shop.account_list("inventory", like_str="%%"+query+"%%") ] + db.commit() + return alist + + @QtCore.Slot('QVariant', 'QVariant', result='QVariant') + def addBarcode(self, dbid, barcode): + acct = brmbar.Account.load(db, id = dbid).add_barcode(barcode) + db.commit() + return acct + + @QtCore.Slot('QVariant', 'QVariant', result='QVariant') + def saveItem(self, dbid, invmap): + acct = brmbar.Account.load(db, id = dbid) + if (acct.name != invmap["name"]): + acct.rename(invmap["name"]) + buy, sell = acct.currency.rates(currency) + if (sell != invmap["price"]): + acct.currency.update_sell_rate(currency, invmap["price"]) + if (buy != invmap["buy_price"]): + acct.currency.update_buy_rate(currency, invmap["buy_price"]) + cost = "" + if (acct.balance() < int(invmap["balance"])): + cost = shop.buy_for_cash(acct, invmap["balance"] - acct.balance()) + else: + db.commit() + return { "dbid": dbid, "cost": (currency.str(cost) if cost != "" else "") } + + @QtCore.Slot('QVariant', result='QVariant') + def newItem(self, invmap): + if (invmap["name"] == "" or invmap["price"] == "" or invmap["buy_price"] == ""): + return None + invcurrency = brmbar.Currency.create(db, invmap["name"]) + invcurrency.update_sell_rate(currency, invmap["price"]) + invcurrency.update_buy_rate(currency, invmap["buy_price"]) + acct = brmbar.Account.create(db, invmap["name"], invcurrency, "inventory") + cost = "" + if (int(invmap["balance"]) > 0): + cost = shop.buy_for_cash(acct, invmap["balance"]) # implicit db.commit() + else: + db.commit() + return { "dbid": acct.id, "cost": (currency.str(cost) if cost != "" else "") } + + @QtCore.Slot('QVariant', 'QVariant', 'QVariant', result='QVariant') + def newReceipt(self, userid, description, amount): + if (description == "" or amount == ""): + return None + user = brmbar.Account.load(db, id = userid) + shop.receipt_to_credit(user, amount, description) + balance = user.negbalance_str() + db.commit() + return balance + +db = Database.Database("dbname=brmbar") shop = brmbar.Shop.new_with_defaults(db) currency = shop.currency +db.commit() app = QtGui.QApplication(sys.argv) diff --git a/brmbar3/brmbar-gui-qt4/BarButton.qml b/brmbar3/brmbar-gui-qt4/BarButton.qml index a3451fb..8718312 100644 --- a/brmbar3/brmbar-gui-qt4/BarButton.qml +++ b/brmbar3/brmbar-gui-qt4/BarButton.qml @@ -1,4 +1,3 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 Rectangle { @@ -10,7 +9,7 @@ Rectangle { property string text: "Button" property int fontSize: 0.768 * 60 - property variant btnColor: "#ffffff" + property string btnColor: "#aaaaaa" signal buttonClick onButtonClick: { /* Supplied by component user. */ } diff --git a/brmbar3/brmbar-gui-qt4/BarClock.qml b/brmbar3/brmbar-gui-qt4/BarClock.qml index c348457..a444a2e 100644 --- a/brmbar3/brmbar-gui-qt4/BarClock.qml +++ b/brmbar3/brmbar-gui-qt4/BarClock.qml @@ -1,4 +1,3 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 Rectangle { @@ -6,8 +5,8 @@ Rectangle { width: 320 height: 65 property variant now: new Date() - property variant textColor: "#000000" - property variant textSize: 0.768 * 16 + property string textColor: "#000000" + property real textSize: 0.768 * 16 Timer { id: clockUpdater interval: 1000 // update clock every second diff --git a/brmbar3/brmbar-gui-qt4/BarKeyPad.qml b/brmbar3/brmbar-gui-qt4/BarKeyPad.qml index bed9f02..c613f4f 100644 --- a/brmbar3/brmbar-gui-qt4/BarKeyPad.qml +++ b/brmbar3/brmbar-gui-qt4/BarKeyPad.qml @@ -4,6 +4,6 @@ BarKeyboard { keys: "0123456789=0) { + balance = shop.chargeCredit(amount, userdbid) + status_text.setStatus("Charged "+amount+"! "+username+"'s credit is "+balance+".", "#ffff7c") + } else { + balance = shop.withdrawCredit((amount*(-1)), userdbid) + status_text.setStatus("Withdrawn "+amount+"! "+username+"'s credit is "+balance+".", "#ffff7c") + } + } loadPage("MainPage") + } } diff --git a/brmbar3/brmbar-gui-qt4/ItemEdit.qml b/brmbar3/brmbar-gui-qt4/ItemEdit.qml index d5ecb27..f03504c 100644 --- a/brmbar3/brmbar-gui-qt4/ItemEdit.qml +++ b/brmbar3/brmbar-gui-qt4/ItemEdit.qml @@ -1,16 +1,14 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 -import QtQuick 1.0 Item { id: page anchors.fill: parent - property variant item_name: item_name_pad.enteredText - property variant dbid: "" + property string item_name: item_name_pad.enteredText + property string dbid: "" property variant info: "" - property variant buy_price: item_buyprice_pad.enteredText - property variant price: item_sellprice_pad.enteredText + property string buy_price: item_buyprice_pad.enteredText + property string price: item_sellprice_pad.enteredText property string barcode: "" state: "normal" @@ -28,7 +26,7 @@ Item { /* TODO: Allow override. */ return } - if (info.dbid == "") { + if (info.dbid === "") { status_text.setStatus("Press [Create] first", "#ff4444") return } @@ -60,7 +58,7 @@ Item { BarButton { id: item_name_edit - x: 534 + x: 790 y: 0 width: 240 height: 60 @@ -115,7 +113,7 @@ Item { BarButton { id: item_buyprice_edit - x: 534 + x: 790 y: 0 width: 240 height: 60 @@ -170,7 +168,7 @@ Item { BarButton { id: item_sellprice_edit - x: 534 + x: 790 y: 0 width: 240 height: 60 @@ -225,7 +223,7 @@ Item { BarButton { id: item_balance_restock - x: 534 + x: 790 y: 0 width: 240 height: 60 @@ -306,46 +304,46 @@ Item { BarButton { id: save x: 65 - y: 582 + y: 838 width: 360 text: dbid == "" ? "Create" : "Save" onButtonClick: { - var xi = info; - xi["name"] = page.item_name; - xi["buy_price"] = page.buy_price; - xi["price"] = page.price; - info = xi + var xi = info; + xi["name"] = page.item_name; + xi["buy_price"] = page.buy_price; + xi["price"] = page.price; + info = xi - var res; - if (dbid == "") { - res = shop.newItem(info) - if (!res) { - status_text.setStatus("Please fill all values first.", "#ff4444") - return - } - } else { - res = shop.saveItem(dbid, info) - } + var res; + if (dbid == "") { + res = shop.newItem(info) + if (!res) { + status_text.setStatus("Please fill all values first.", "#ff4444") + return + } + } else { + res = shop.saveItem(dbid, info) + } - if (res.cost) { - status_text.setStatus((dbid == "" ? "Stocked!" : "Restocked!") + " Take " + res.cost + " from the money box.", "#ffff7c") - } else { - status_text.setStatus(dbid == "" ? "Item created" : "Changes saved", "#ffff7c") - } + if (res.cost) { + status_text.setStatus((dbid == "" ? "Stocked!" : "Restocked!") + " Take " + res.cost + " from the money box.", "#ffff7c") + } else { + status_text.setStatus(dbid == "" ? "Item created" : "Changes saved", "#ffff7c") + } - if (dbid == "") { - dbid = res.dbid - var xi = info; xi["dbid"] = page.dbid; info = xi - } else { - loadPage("StockMgmt") - } + if (dbid == "") { + dbid = res.dbid + xi = info; xi["dbid"] = page.dbid; info = xi + } else { + loadPage("StockMgmt") + } } } BarButton { id: cancel - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Cancel" onButtonClick: { diff --git a/brmbar3/brmbar-gui-qt4/ItemInfo.qml b/brmbar3/brmbar-gui-qt4/ItemInfo.qml index 8d765b0..11e959d 100644 --- a/brmbar3/brmbar-gui-qt4/ItemInfo.qml +++ b/brmbar3/brmbar-gui-qt4/ItemInfo.qml @@ -1,14 +1,12 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 -import QtQuick 1.0 Item { id: page anchors.fill: parent - property variant name: "" - property variant dbid: "" - property variant price: "" + property string name: "" + property string dbid: "" + property string price: "" Text { id: item_name @@ -25,7 +23,7 @@ Item { Text { id: text3 - x: 611 + x: 867 y: 156 height: 160 width: 348 @@ -52,12 +50,20 @@ Item { status_text.setStatus("Unknown barcode", "#ff4444") return } - if (acct.acctype != "debt") { + if (acct.acctype !== "debt" && acct.acctype !== "cash") { loadPageByAcct(acct) return } - var balance = shop.sellItem(dbid, acct.id) - status_text.setStatus("Sold! "+acct.name+"'s credit is "+balance+".", "#ffff7c") + + if (acct.acctype == "cash") { //Copied from BarButton.onButtonClick + shop.sellItemCash(dbid) + status_text.setStatus("Sold! Put " + price + " Kč in the money box.", "#ffff7c") + } else if (!shop.canSellItem(dbid, acct.id)) { + status_text.setStatus("NOT SOLD! "+acct.name+"'s credit is TOO LOW: "+shop.balance_user(acct.id), "#ff4444") + } else { + var balance = shop.sellItem(dbid, acct.id) + status_text.setStatus("Sold! "+acct.name+"'s credit is "+balance+".", "#ffff7c") + } loadPage("MainPage") } } @@ -65,7 +71,7 @@ Item { BarButton { id: pay_cash x: 65 - y: 582 + y: 838 width: 360 text: "Pay by cash" fontSize: 0.768 * 60 @@ -78,8 +84,8 @@ Item { BarButton { id: cancel - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Cancel" onButtonClick: { diff --git a/brmbar3/brmbar-gui-qt4/MainPage.qml b/brmbar3/brmbar-gui-qt4/MainPage.qml index cb4f980..d11fd0f 100644 --- a/brmbar3/brmbar-gui-qt4/MainPage.qml +++ b/brmbar3/brmbar-gui-qt4/MainPage.qml @@ -1,6 +1,4 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 -import QtQuick 1.0 Item { id: page @@ -26,44 +24,40 @@ Item { } BarButton { - id: withdraw x: 65 - y: 430 + y: 838 width: 360 - text: "Select Item" - fontSize: 0.768 * 60 - btnColor: "#666666" - } - - BarButton { - x: 599 - y: 430 - width: 360 - text: "Credit" + text: "Charge" onButtonClick: { loadPage("ChargeCredit") } } BarButton { - id: select_item - x: 65 - y: 582 + x: 450 + y: 838 width: 360 - text: "Receipt" + text: "Transfer" onButtonClick: { - loadPage("Receipt") + loadPage("Transfer") } } BarButton { id: management - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Management" onButtonClick: { loadPage("Management") } } + + BarButton { + x: 65 + y: 438 + width: 1150 + text: "* Za uklid brmlabu vam nabijeme kredit. *" + } } diff --git a/brmbar3/brmbar-gui-qt4/Management.qml b/brmbar3/brmbar-gui-qt4/Management.qml index 80f728d..6cb416a 100644 --- a/brmbar3/brmbar-gui-qt4/Management.qml +++ b/brmbar3/brmbar-gui-qt4/Management.qml @@ -1,6 +1,4 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 -import QtQuick 1.0 Item { id: page @@ -65,7 +63,7 @@ Item { Text { id: credit_name - x: 535 + x: 791 y: 156 width: 337 height: 160 @@ -78,7 +76,7 @@ Item { Text { id: credit_amount - x: 705 + x: 961 y: 156 height: 160 width: 254 @@ -91,7 +89,7 @@ Item { Text { id: inv_name - x: 535 + x: 791 y: 266 width: 337 height: 160 @@ -104,7 +102,7 @@ Item { Text { id: inv_amount - x: 705 + x: 961 y: 266 height: 160 width: 254 @@ -118,7 +116,7 @@ Item { BarButton { id: stock_manager x: 65 - y: 430 + y: 686 width: 360 text: "Stock Mgmt" onButtonClick: { @@ -128,8 +126,8 @@ Item { BarButton { id: user_manager - x: 599 - y: 430 + x: 855 + y: 686 width: 360 text: "User Mgmt" onButtonClick: { @@ -137,10 +135,21 @@ Item { } } + BarButton { + id: select_item + x: 65 + y: 838 + width: 360 + text: "Receipt" + onButtonClick: { + loadPage("Receipt") + } + } + BarButton { id: cancel - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Main Screen" onButtonClick: { diff --git a/brmbar3/brmbar-gui-qt4/Receipt.qml b/brmbar3/brmbar-gui-qt4/Receipt.qml index 5e7288b..76855ac 100644 --- a/brmbar3/brmbar-gui-qt4/Receipt.qml +++ b/brmbar3/brmbar-gui-qt4/Receipt.qml @@ -1,14 +1,12 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 -import QtQuick 1.0 Item { id: page anchors.fill: parent property variant user - property variant description: item_name_pad.enteredText - property variant amount: amount_pad.enteredText + property string description: item_name_pad.enteredText + property string amount: amount_pad.enteredText state: "normal" @@ -49,7 +47,7 @@ Item { BarButton { id: description_edit - x: 591 + x: 847 y: 0 width: 300 height: 60 @@ -104,7 +102,7 @@ Item { BarButton { id: amount_edit - x: 650 + x: 906 y: 0 width: 240 height: 60 @@ -150,7 +148,7 @@ Item { BarButton { id: save x: 65 - y: 582 + y: 838 width: 360 text: "Create" onButtonClick: { @@ -171,8 +169,8 @@ Item { BarButton { id: cancel - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Cancel" onButtonClick: { diff --git a/brmbar3/brmbar-gui-qt4/StockMgmt.qml b/brmbar3/brmbar-gui-qt4/StockMgmt.qml index 68a6838..4841865 100644 --- a/brmbar3/brmbar-gui-qt4/StockMgmt.qml +++ b/brmbar3/brmbar-gui-qt4/StockMgmt.qml @@ -1,6 +1,4 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 -import QtQuick 1.0 Item { id: page @@ -8,6 +6,8 @@ Item { property variant item_list_model + state: "normal" + BarcodeInput { color: "#00ff00" /* just for debugging */ onAccepted: { @@ -29,8 +29,8 @@ Item { id: item_list_container x: 65 y: 166 - width: 899 - height: 400 + width: 1155 + height: 656 ListView { id: item_list @@ -49,7 +49,7 @@ Item { Text { anchors.verticalCenter: parent.verticalCenter - x: 300 + x: 556 width: 254 color: "#ffff7c" text: modelData.price @@ -59,7 +59,7 @@ Item { BarButton { anchors.verticalCenter: parent.verticalCenter - x: 600 + x: 856 width: 240 height: 68 text: "Edit" @@ -81,12 +81,14 @@ Item { } BarButton { - id: add_item + id: new_item x: 65 - y: 582 - width: 360 - text: "Add Item" + y: 838 + width: 281 + height: 83 + text: "New Item" fontSize: 0.768 * 60 + visible: page.state == "normal" onButtonClick: { loadPage("ItemEdit", { dbid: "" }) } @@ -94,16 +96,96 @@ Item { BarButton { id: cancel - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Main Screen" onButtonClick: { - loadPage("MainPage") + if (page.state == "search") + page.state = "normal" + else + loadPage("MainPage") } } + BarButton { + id: search_button + x: 353 + y: 838 + text: "Search" + visible: page.state == "normal" + onButtonClick: { page.state = "search" } + } + + BarKeyPad { + id: search_pad + x: 193 + y: 554 + opacity: 0 + } + + Text { + id: search_text + x: 65 + y: 602 + color: "#ffff7c" + text: search_pad.enteredText + visible: page.state == "search" + font.pixelSize: 0.768 * 46 + opacity: 0 + } + + BarButton { + id: query_button + x: 353 + y: 838 + text: "Search" + visible: page.state == "search" + onButtonClick: { + page.item_list_model = shop.itemList(search_pad.enteredText) + item_list.model = page.item_list_model + } + } + + states: [ + State { + name: "normal" + }, + State { + name: "search" + + PropertyChanges { + target: item_list_container + x: 66 + y: 166 + width: 1155 + height: 348 + } + + PropertyChanges { + target: search_pad + x: 83 + y: 514 + opacity: 1 + } + + PropertyChanges { + target: cancel + text: "Back" + } + + PropertyChanges { + target: search_text + x: 65 + y: 838 + width: 528 + height: 83 + opacity: 1 + } + } + ] + Component.onCompleted: { - item_list_model = shop.itemList() + item_list_model = shop.itemList("") } } diff --git a/brmbar3/brmbar-gui-qt4/Transfer.qml b/brmbar3/brmbar-gui-qt4/Transfer.qml new file mode 100644 index 0000000..d6a3a4e --- /dev/null +++ b/brmbar3/brmbar-gui-qt4/Transfer.qml @@ -0,0 +1,174 @@ +import QtQuick 1.1 + +Item { + id: page + anchors.fill: parent + + property variant userfrom: "" + property variant uidfrom: "" + property variant userto: "" + property variant uidto: "" + property string amount: amount_pad.enteredText + + BarcodeInput { + color: "#00ff00" /* just for debugging */ + focus: !(parent.userfrom != "" && parent.userto != "") + onAccepted: { + var acct = shop.barcodeInput(text) + text = "" + if (typeof(acct) == "undefined") { + status_text.setStatus("Unknown barcode", "#ff4444") + return + } + if (acct.acctype == "debt") { + if (userfrom == "") { + userfrom = acct.name + uidfrom = acct.id + } else { + userto = acct.name + uidto = acct.id + } + } else if (acct.acctype == "recharge") { + amount = acct.amount + } else { + status_text.setStatus("Unknown barcode", "#ff4444") + } + } + } + + Item { + id: amount_row + visible: parent.userfrom != "" && parent.userto != "" + x: 65; + y: 166; + width: 890 + height: 60 + + Text { + id: item_sellprice_label + x: 0 + y: 0 + height: 60 + width: 200 + color: "#ffffff" + text: "Money Amount:" + verticalAlignment: Text.AlignVCenter + font.pixelSize: 0.768 * 46 + } + + Text { + id: amount_input + x: 320 + y: 0 + height: 60 + width: 269 + color: "#ffff7c" + text: amount + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + font.pixelSize: 0.768 * 122 + } + } + + BarNumPad { + id: amount_pad + x: 65 + y: 239 + visible: parent.userfrom != "" && parent.userto != "" + focus: parent.userfrom != "" && parent.userto != "" + Keys.onReturnPressed: { transfer.buttonClick() } + Keys.onEscapePressed: { cancel.buttonClick() } + } + + BarTextHint { + id: barcode_row + x: 65 + y: parent.userfrom == "" ? 314 : 414 + hint_goal: (parent.userfrom == "" ? "Take money from:" : parent.userto == "" ? "Give money to:" : parent.amount == "" ? "Specify amount" : "") + hint_action: (parent.userfrom == "" || parent.userto == "" ? "Scan barcode now" : (parent.amount ? "" : "(or scan barcode now)")) + } + + Text { + id: legend + visible: !(parent.userfrom != "" && parent.userto != "") + x: 65 + y: 611 + height: 154 + width: 894 + color: "#71cccc" + text: "This is for transfering credit between two brmbar users.\n May be used instead of *check next club-mate to me*." + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + font.pixelSize: 0.768 * 27 + } + + Text { + id: item_name + x: 422 + y: 156 + width: 537 + height: 80 + color: "#ffffff" + text: parent.userfrom ? parent.userfrom + " →" : "Money Transfer" + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + font.pixelSize: 0.768 * 60 + } + + Text { + id: item_name2 + x: 422 + y: 256 + width: 537 + height: 80 + color: "#ffffff" + text: parent.userto ? "→ " + parent.userto : "" + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + font.pixelSize: 0.768 * 60 + } + + BarButton { + id: transfer + x: 65 + y: 838 + width: 360 + text: "Transfer" + onButtonClick: { + if (userfrom == "") { + status_text.setStatus("Select FROM account.", "#ff4444") + return + } + if (userto == "") { + status_text.setStatus("Select TO account.", "#ff4444") + return + } + if (amount == "") { + status_text.setStatus("Enter amount.", "#ff4444") + return + } + var amount_str = shop.newTransfer(uidfrom, uidto, amount) + if (typeof(amount_str) == "undefined") { + status_text.setStatus("Transfer error.", "#ff4444") + return + } + + status_text.setStatus("Transferred " + amount_str + " from " + userfrom + " to " + userto, "#ffff7c") + loadPage("MainPage") + } + } + + BarButton { + id: cancel + x: 855 + y: 838 + width: 360 + text: "Cancel" + onButtonClick: { + status_text.setStatus("Transfer cancelled", "#ff4444") + loadPage("MainPage") + } + } +} diff --git a/brmbar3/brmbar-gui-qt4/UserInfo.qml b/brmbar3/brmbar-gui-qt4/UserInfo.qml index ab4b0c2..80e0887 100644 --- a/brmbar3/brmbar-gui-qt4/UserInfo.qml +++ b/brmbar3/brmbar-gui-qt4/UserInfo.qml @@ -1,13 +1,12 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 Item { id: page anchors.fill: parent - property variant name: "" - property variant dbid: "" - property variant negbalance: "" + property string name: "" + property string dbid: "" + property string negbalance: "" Text { id: item_name @@ -43,7 +42,7 @@ Item { status_text.setStatus("Unknown barcode", "#ff4444") return } - if (acct.acctype == "recharge") { + if (acct.acctype === "recharge") { loadPage("ChargeCredit", { "username": name, "userdbid": dbid, "amount": acct.amount }) return } @@ -55,7 +54,7 @@ Item { BarButton { id: charge_credit x: 65 - y: 582 + y: 838 width: 360 text: "Charge" fontSize: 0.768 * 60 @@ -66,8 +65,8 @@ Item { BarButton { id: cancel - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Main Screen" onButtonClick: { diff --git a/brmbar3/brmbar-gui-qt4/UserMgmt.qml b/brmbar3/brmbar-gui-qt4/UserMgmt.qml index f2c7a58..da1c7c8 100644 --- a/brmbar3/brmbar-gui-qt4/UserMgmt.qml +++ b/brmbar3/brmbar-gui-qt4/UserMgmt.qml @@ -1,6 +1,4 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 -import QtQuick 1.0 Item { id: page @@ -17,12 +15,12 @@ Item { status_text.setStatus("Unknown barcode", "#ff4444") return } - if (acct.acctype != "debt") { + if (acct.acctype !== "debt") { loadPageByAcct(acct) return } /* TODO: This should be UserEdit when implemented. */ - loadPage("Withdraw", { name: acct["name"], dbid: acct["id"], negbalance: acct["negbalance"] }) + loadPage("Withdraw", { username: acct["name"], userdbid: acct["id"] }) } } @@ -30,8 +28,8 @@ Item { id: user_list_container x: 65 y: 166 - width: 899 - height: 400 + width: 1155 + height: 656 ListView { id: user_list @@ -50,7 +48,7 @@ Item { Text { anchors.verticalCenter: parent.verticalCenter - x: 300 + x: 556 width: 254 color: "#ffff7c" text: modelData.negbalance_str @@ -60,7 +58,7 @@ Item { BarButton { anchors.verticalCenter: parent.verticalCenter - x: 600 + x: 856 width: 240 height: 68 text: "Withdraw" @@ -84,7 +82,7 @@ Item { BarButton { id: add_user x: 65 - y: 582 + y: 838 width: 360 text: "Add User" fontSize: 0.768 * 60 @@ -93,8 +91,8 @@ Item { BarButton { id: cancel - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Main Screen" onButtonClick: { diff --git a/brmbar3/brmbar-gui-qt4/Withdraw.qml b/brmbar3/brmbar-gui-qt4/Withdraw.qml index a936f7e..2d8c49b 100644 --- a/brmbar3/brmbar-gui-qt4/Withdraw.qml +++ b/brmbar3/brmbar-gui-qt4/Withdraw.qml @@ -1,14 +1,12 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 -import QtQuick 1.0 Item { id: page anchors.fill: parent - property variant username: "" - property variant userdbid: "" - property variant amount: withdraw_pad.enteredText + property string username: "" + property string userdbid: "" + property string amount: withdraw_pad.enteredText Text { id: item_name @@ -39,7 +37,7 @@ Item { BarTextHint { x: 65 - y: 430 + y: 686 hint_goal: (parent.username ? "" : parent.amount ? "Withdraw:" : "Withdraw amount?") hint_action: (parent.username ? (parent.amount ? "" : "(or scan barcode now)") : "Scan barcode now") } @@ -79,7 +77,7 @@ Item { BarButton { id: withdraw_button x: 65 - y: 582 + y: 838 width: 360 text: "Withdraw" fontSize: 0.768 * 60 @@ -91,8 +89,8 @@ Item { BarButton { id: cancel - x: 599 - y: 582 + x: 855 + y: 838 width: 360 text: "Cancel" onButtonClick: { @@ -102,8 +100,17 @@ Item { } function withdrawCredit() { - var balance = shop.withdrawCredit(amount, userdbid) - status_text.setStatus("Withdrawn! "+username+"'s credit is "+balance+".", "#ffff7c") + var balance=0 + if (!isNaN(amount)) { + amount=(amount*1) + if(amount>=0) { + balance = shop.withdrawCredit(amount, userdbid) + status_text.setStatus("Withdrawn "+amount+"! "+username+"'s credit is "+balance+".", "#ffff7c") + } else { + balance = shop.chargeCredit((amount*(-1)),userdbid) + status_text.setStatus("Charged "+amount+"! "+username+"'s credit is "+balance+".", "#ffff7c") + } + } loadPage("MainPage") } } diff --git a/brmbar3/brmbar-gui-qt4/brmbar-gui-qt4.qmlproject.user b/brmbar3/brmbar-gui-qt4/brmbar-gui-qt4.qmlproject.user index eeee7ab..0a23204 100644 --- a/brmbar3/brmbar-gui-qt4/brmbar-gui-qt4.qmlproject.user +++ b/brmbar3/brmbar-gui-qt4/brmbar-gui-qt4.qmlproject.user @@ -1,6 +1,6 @@ - + ProjectExplorer.Project.ActiveTarget @@ -112,7 +112,7 @@ ProjectExplorer.Project.Updater.EnvironmentId - {a277f310-b549-4ad7-87ca-cd03f76f19ff} + {524378aa-09e0-4345-892b-1bd47313bcaf} ProjectExplorer.Project.Updater.FileVersion diff --git a/brmbar3/brmbar-gui-qt4/brmlab.svg b/brmbar3/brmbar-gui-qt4/brmlab.svg index d6803f6..b565ed2 100644 --- a/brmbar3/brmbar-gui-qt4/brmlab.svg +++ b/brmbar3/brmbar-gui-qt4/brmlab.svg @@ -83,106 +83,106 @@ diff --git a/brmbar3/brmbar-gui-qt4/main.qml b/brmbar3/brmbar-gui-qt4/main.qml index 5c43528..9eefbc6 100644 --- a/brmbar3/brmbar-gui-qt4/main.qml +++ b/brmbar3/brmbar-gui-qt4/main.qml @@ -1,4 +1,3 @@ -// import QtQuick 1.0 // to target S60 5th Edition or Maemo 5 import QtQuick 1.1 BasePage { @@ -17,11 +16,11 @@ BasePage { } function loadPageByAcct(acct) { - if (acct.acctype == "inventory") { + if (acct.acctype === "inventory") { loadPage("ItemInfo", { name: acct["name"], dbid: acct["id"], price: acct["price"] }) - } else if (acct.acctype == "debt") { + } else if (acct.acctype === "debt") { loadPage("UserInfo", { name: acct["name"], dbid: acct["id"], negbalance: acct["negbalance"] }) - } else if (acct.acctype == "recharge") { + } else if (acct.acctype === "recharge") { loadPage("ChargeCredit", { amount: acct["amount"] }) } } diff --git a/brmbar3/brmbar-tui.py b/brmbar3/brmbar-tui.py new file mode 100755 index 0000000..00bdf44 --- /dev/null +++ b/brmbar3/brmbar-tui.py @@ -0,0 +1,62 @@ +#!/usr/bin/python3 + +import sys + +from brmbar import Database + +import brmbar + +db = Database.Database("dbname=brmbar") +shop = brmbar.Shop.new_with_defaults(db) +currency = shop.currency + +active_inv_item = None +active_credit = None + +for line in sys.stdin: + barcode = line.rstrip() + + if barcode[0] == "$": + credits = {'$02': 20, '$05': 50, '$10': 100, '$20': 200, '$50': 500, '$1k': 1000} + credit = credits[barcode] + if credit is None: + print("Unknown barcode: " + barcode) + continue + print("CREDIT " + str(credit)) + active_inv_item = None + active_credit = credit + continue + + if barcode == "SCR": + print("SHOW CREDIT") + active_inv_item = None + active_credit = None + continue + + acct = brmbar.Account.load_by_barcode(db, barcode) + if acct is None: + print("Unknown barcode: " + barcode) + continue + + if acct.acctype == 'debt': + if active_inv_item is not None: + cost = shop.sell(item = active_inv_item, user = acct) + print("{} has bought {} for {} and now has {} balance".format(acct.name, active_inv_item.name, currency.str(cost), acct.negbalance_str())) + elif active_credit is not None: + shop.add_credit(credit = active_credit, user = acct) + print("{} has added {} credit and now has {} balance".format(acct.name, currency.str(active_credit), acct.negbalance_str())) + else: + print("{} has {} balance".format(acct.name, acct.negbalance_str())) + active_inv_item = None + active_credit = None + + elif acct.acctype == 'inventory': + buy, sell = acct.currency.rates(currency) + print("{} costs {} with {} in stock".format(acct.name, currency.str(sell), int(acct.balance()))) + active_inv_item = acct + active_credit = None + + else: + print("invalid account type {}".format(acct.acctype)) + active_inv_item = None + active_credit = None diff --git a/brmbar3/brmbar-web.py b/brmbar3/brmbar-web.py new file mode 100755 index 0000000..5d84378 --- /dev/null +++ b/brmbar3/brmbar-web.py @@ -0,0 +1,45 @@ +#!/usr/bin/python + +import sys + +from brmbar import Database + +import brmbar + +from flask import * +app = Flask(__name__) +#app.debug = True + +@app.route('/stock/') +def stock(show_all=False): + # TODO: Use a fancy template. + # FIXME: XSS protection. + response = '' + for a in shop.account_list("inventory"): + style = '' + balance = a.balance() + if balance == 0: + if not show_all: + continue + style = 'color: grey; font-style: italic' + elif balance < 0: + style = 'color: red' + response += '' % (style, a.id, a.name, balance) + response += '
IdItem NameBal.
%d%s%d
' + if show_all: + response += '

(hide out-of-stock items)

' + else: + response += '

(show all items)

' + return response + +@app.route('/stock/all') +def stockall(): + return stock(show_all=True) + + +db = Database.Database("dbname=brmbar") +shop = brmbar.Shop.new_with_defaults(db) +currency = shop.currency + +if __name__ == '__main__': + app.run(host='0.0.0.0') diff --git a/brmbar3/brmbar/Account.py b/brmbar3/brmbar/Account.py index e9107eb..215df11 100644 --- a/brmbar3/brmbar/Account.py +++ b/brmbar3/brmbar/Account.py @@ -1,90 +1,84 @@ from .Currency import Currency -import psycopg2 -from contextlib import closing - class Account: - """ BrmBar Account + """ BrmBar Account - Both users and items are accounts. So is the money box, etc. - Each account has a currency.""" - def __init__(self, db, id, name, currency, acctype): - self.db = db - self.id = id - self.name = name - self.currency = currency - self.acctype = acctype + Both users and items are accounts. So is the money box, etc. + Each account has a currency.""" + def __init__(self, db, id, name, currency, acctype): + self.db = db + self.id = id + self.name = name + self.currency = currency + self.acctype = acctype - @classmethod - def load_by_barcode(cls, db, barcode): - with closing(db.cursor()) as cur: - cur.execute("SELECT account FROM barcodes WHERE barcode = %s", [barcode]) - res = cur.fetchone() - if res is None: - return None - id = res[0] - return cls.load(db, id = id) + @classmethod + def load_by_barcode(cls, db, barcode): + res = db.execute_and_fetch("SELECT account FROM barcodes WHERE barcode = %s", [barcode]) + if res is None: + return None + id = res[0] + return cls.load(db, id = id) - @classmethod - def load(cls, db, id = None, name = None): - """ Constructor for existing account """ - if id is not None: - with closing(db.cursor()) as cur: - cur.execute("SELECT name FROM accounts WHERE id = %s", [id]) - name = cur.fetchone()[0] - elif name is not None: - with closing(db.cursor()) as cur: - cur.execute("SELECT id FROM accounts WHERE name = %s", [name]) - id = cur.fetchone()[0] - else: - raise NameError("Account.load(): Specify either id or name") + @classmethod + def load(cls, db, id = None, name = None): + """ Constructor for existing account """ + if id is not None: + name = db.execute_and_fetch("SELECT name FROM accounts WHERE id = %s", [id]) + name = name[0] + elif name is not None: + id = db.execute_and_fetch("SELECT id FROM accounts WHERE name = %s", [name]) + id = id[0] + else: + raise NameError("Account.load(): Specify either id or name") - with closing(db.cursor()) as cur: - cur.execute("SELECT currency, acctype FROM accounts WHERE id = %s", [id]) - currid, acctype = cur.fetchone() - currency = Currency.load(db, id = currid) + currid, acctype = db.execute_and_fetch("SELECT currency, acctype FROM accounts WHERE id = %s", [id]) + currency = Currency.load(db, id = currid) - return cls(db, name = name, id = id, currency = currency, acctype = acctype) + return cls(db, name = name, id = id, currency = currency, acctype = acctype) - @classmethod - def create(cls, db, name, currency, acctype): - """ Constructor for new account """ - with closing(db.cursor()) as cur: - cur.execute("INSERT INTO accounts (name, currency, acctype) VALUES (%s, %s, %s) RETURNING id", [name, currency.id, acctype]) - id = cur.fetchone()[0] - return cls(db, name = name, id = id, currency = currency, acctype = acctype) + @classmethod + def create(cls, db, name, currency, acctype): + """ Constructor for new account """ + # id = db.execute_and_fetch("INSERT INTO accounts (name, currency, acctype) VALUES (%s, %s, %s) RETURNING id", [name, currency.id, acctype]) + id = db.execute_and_fetch("SELECT public.create_account(%s, %s, %s)", [name, currency.id, acctype]) + # id = id[0] + return cls(db, name = name, id = id, currency = currency, acctype = acctype) - def balance(self): - with closing(self.db.cursor()) as cur: - cur.execute("SELECT SUM(amount) FROM transaction_splits WHERE account = %s AND side = %s", [self.id, 'debit']) - debit = cur.fetchone()[0] or 0 - cur.execute("SELECT SUM(amount) FROM transaction_splits WHERE account = %s AND side = %s", [self.id, 'credit']) - credit = cur.fetchone()[0] or 0 - return debit - credit + def balance(self): + bal = self.db.execute_and_fetch( + "SELECT public.compute_account_balance(%s)", + [self.id] + )[0] + return bal + #debit = self.db.execute_and_fetch("SELECT SUM(amount) FROM transaction_splits WHERE account = %s AND side = %s", [self.id, 'debit']) + #debit = debit[0] or 0 + #credit = self.db.execute_and_fetch("SELECT SUM(amount) FROM transaction_splits WHERE account = %s AND side = %s", [self.id, 'credit']) + #credit = credit[0] or 0 + #return debit - credit - def balance_str(self): - return self.currency.str(self.balance()) + def balance_str(self): + return self.currency.str(self.balance()) - def negbalance_str(self): - return self.currency.str(-self.balance()) + def negbalance_str(self): + return self.currency.str(-self.balance()) - def debit(self, transaction, amount, memo): - return self._transaction_split(transaction, 'debit', amount, memo) + def debit(self, transaction, amount, memo): + return self._transaction_split(transaction, 'debit', amount, memo) - def credit(self, transaction, amount, memo): - return self._transaction_split(transaction, 'credit', amount, memo) + def credit(self, transaction, amount, memo): + return self._transaction_split(transaction, 'credit', amount, memo) - def _transaction_split(self, transaction, side, amount, memo): - """ Common part of credit() and debit(). """ - with closing(self.db.cursor()) as cur: - cur.execute("INSERT INTO transaction_splits (transaction, side, account, amount, memo) VALUES (%s, %s, %s, %s, %s)", [transaction, side, self.id, amount, memo]) + def _transaction_split(self, transaction, side, amount, memo): + """ Common part of credit() and debit(). """ + self.db.execute("INSERT INTO transaction_splits (transaction, side, account, amount, memo) VALUES (%s, %s, %s, %s, %s)", [transaction, side, self.id, amount, memo]) - def add_barcode(self, barcode): - with closing(self.db.cursor()) as cur: - cur.execute("INSERT INTO barcodes (account, barcode) VALUES (%s, %s)", [self.id, barcode]) - self.db.commit() + def add_barcode(self, barcode): + # self.db.execute("INSERT INTO barcodes (account, barcode) VALUES (%s, %s)", [self.id, barcode]) + self.db.execute("SELECT public.add_barcode_to_account(%s, %s)", [self.id, barcode]) + self.db.commit() - def rename(self, name): - with closing(self.db.cursor()) as cur: - cur.execute("UPDATE accounts SET name = %s WHERE id = %s", [name, self.id]) - self.name = name + def rename(self, name): + # self.db.execute("UPDATE accounts SET name = %s WHERE id = %s", [name, self.id]) + self.db.execute("SELECT public.rename_account(%s, %s)", [self.id, name]) + self.name = name diff --git a/brmbar3/brmbar/Currency.py b/brmbar3/brmbar/Currency.py index 8aeaf61..29da4a3 100644 --- a/brmbar3/brmbar/Currency.py +++ b/brmbar3/brmbar/Currency.py @@ -1,85 +1,98 @@ -import psycopg2 -from contextlib import closing +# vim: set fileencoding=utf8 class Currency: - """ Currency - - Each account has a currency (1 Kč, 1 Club Maté, ...), pairs of - currencies have (asymmetric) exchange rates. """ - def __init__(self, db, id, name): - self.db = db - self.id = id - self.name = name + """ Currency + + Each account has a currency (1 Kč, 1 Club Maté, ...), pairs of + currencies have (asymmetric) exchange rates. """ + def __init__(self, db, id, name): + self.db = db + self.id = id + self.name = name - @classmethod - def default(cls, db): - """ Default wallet currency """ - return cls.load(db, name = "Kč") + @classmethod + def default(cls, db): + """ Default wallet currency """ + return cls.load(db, name = "Kč") - @classmethod - def load(cls, db, id = None, name = None): - """ Constructor for existing currency """ - if id is not None: - with closing(db.cursor()) as cur: - cur.execute("SELECT name FROM currencies WHERE id = %s", [id]) - name = cur.fetchone()[0] - elif name is not None: - with closing(db.cursor()) as cur: - cur.execute("SELECT id FROM currencies WHERE name = %s", [name]) - id = cur.fetchone()[0] - else: - raise NameError("Currency.load(): Specify either id or name") - return cls(db, name = name, id = id) + @classmethod + def load(cls, db, id = None, name = None): + """ Constructor for existing currency """ + if id is not None: + name = db.execute_and_fetch("SELECT name FROM currencies WHERE id = %s", [id]) + name = name[0] + elif name is not None: + id = db.execute_and_fetch("SELECT id FROM currencies WHERE name = %s", [name]) + id = id[0] + else: + raise NameError("Currency.load(): Specify either id or name") + return cls(db, name = name, id = id) - @classmethod - def create(cls, db, name): - """ Constructor for new currency """ - with closing(db.cursor()) as cur: - cur.execute("INSERT INTO currencies (name) VALUES (%s) RETURNING id", [name]) - id = cur.fetchone()[0] - return cls(db, name = name, id = id) + @classmethod + def create(cls, db, name): + """ Constructor for new currency """ + # id = db.execute_and_fetch("INSERT INTO currencies (name) VALUES (%s) RETURNING id", [name]) + id = db.execute_and_fetch("SELECT public.create_currency(%s)", [name]) + # id = id[0] + return cls(db, name = name, id = id) - def rates(self, other): - """ Return tuple ($buy, $sell) of rates of $self in relation to $other (brmbar.Currency): - $buy is the price of $self in means of $other when buying it (into brmbar) - $sell is the price of $self in means of $other when selling it (from brmbar) """ - with closing(self.db.cursor()) as cur: + def rates(self, other): + """ Return tuple ($buy, $sell) of rates of $self in relation to $other (brmbar.Currency): + $buy is the price of $self in means of $other when buying it (into brmbar) + $sell is the price of $self in means of $other when selling it (from brmbar) """ + # buy rate + res = self.db.execute_and_fetch("SELECT public.find_buy_rate(%s, %s)",[self.id, other.id]) + if res is None: + raise NameError("Something fishy in find_buy_rate."); + buy = res[0] + if buy < 0: + raise NameError("Currency.rate(): Unknown conversion " + other.name() + " to " + self.name()) + # sell rate + res = self.db.execute_and_fetch("SELECT public.find_sell_rate(%s, %s)",[self.id, other.id]) + if res is None: + raise NameError("Something fishy in find_sell_rate."); + sell = res[0] + if sell < 0: + raise NameError("Currency.rate(): Unknown conversion " + self.name() + " to " + other.name()) - cur.execute("SELECT rate, rate_dir FROM exchange_rates WHERE target = %s AND source = %s AND valid_since <= NOW() ORDER BY valid_since DESC LIMIT 1", [self.id, other.id]) - res = cur.fetchone() - if res is None: - raise NameError("Currency.rate(): Unknown conversion " + other.name() + " to " + self.name()) - buy_rate, buy_rate_dir = res - buy = buy_rate if buy_rate_dir == "target_to_source" else 1/buy_rate + return (buy, sell) - cur.execute("SELECT rate, rate_dir FROM exchange_rates WHERE target = %s AND source = %s AND valid_since <= NOW() ORDER BY valid_since DESC LIMIT 1", [other.id, self.id]) - res = cur.fetchone() - if res is None: - raise NameError("Currency.rate(): Unknown conversion " + self.name() + " to " + other.name()) - sell_rate, sell_rate_dir = res - sell = sell_rate if sell_rate_dir == "source_to_target" else 1/sell_rate + def rates2(self, other): + # the original code for compare testing + res = self.db.execute_and_fetch("SELECT rate, rate_dir FROM exchange_rates WHERE target = %s AND source = %s AND valid_since <= NOW() ORDER BY valid_since DESC LIMIT 1", [self.id, other.id]) + if res is None: + raise NameError("Currency.rate(): Unknown conversion " + other.name() + " to " + self.name()) + buy_rate, buy_rate_dir = res + buy = buy_rate if buy_rate_dir == "target_to_source" else 1/buy_rate - return (buy, sell) + res = self.db.execute_and_fetch("SELECT rate, rate_dir FROM exchange_rates WHERE target = %s AND source = %s AND valid_since <= NOW() ORDER BY valid_since DESC LIMIT 1", [other.id, self.id]) + if res is None: + raise NameError("Currency.rate(): Unknown conversion " + self.name() + " to " + other.name()) + sell_rate, sell_rate_dir = res + sell = sell_rate if sell_rate_dir == "source_to_target" else 1/sell_rate - def convert(self, amount, target): - with closing(self.db.cursor()) as cur: - cur.execute("SELECT rate, rate_dir FROM exchange_rates WHERE target = %s AND source = %s AND valid_since <= NOW() ORDER BY valid_since DESC LIMIT 1", [target.id, self.id]) - res = cur.fetchone() - if res is None: - raise NameError("Currency.convert(): Unknown conversion " + self.name() + " to " + other.name()) - rate, rate_dir = res - if rate_dir == "source_to_target": - resamount = amount * rate - else: - resamount = amount / rate - return resamount + return (buy, sell) - def str(self, amount): - return "{:.2f} {}".format(amount, self.name) - def update_sell_rate(self, target, rate): - with closing(self.db.cursor()) as cur: - cur.execute("INSERT INTO exchange_rates (source, target, rate, rate_dir) VALUES (%s, %s, %s, %s)", [self.id, target.id, rate, "source_to_target"]) - def update_buy_rate(self, source, rate): - with closing(self.db.cursor()) as cur: - cur.execute("INSERT INTO exchange_rates (source, target, rate, rate_dir) VALUES (%s, %s, %s, %s)", [source.id, self.id, rate, "target_to_source"]) + def convert(self, amount, target): + res = self.db.execute_and_fetch("SELECT rate, rate_dir FROM exchange_rates WHERE target = %s AND source = %s AND valid_since <= NOW() ORDER BY valid_since DESC LIMIT 1", [target.id, self.id]) + if res is None: + raise NameError("Currency.convert(): Unknown conversion " + self.name() + " to " + target.name()) + rate, rate_dir = res + if rate_dir == "source_to_target": + resamount = amount * rate + else: + resamount = amount / rate + return resamount + + def str(self, amount): + return "{:.2f} {}".format(amount, self.name) + + def update_sell_rate(self, target, rate): + # self.db.execute("INSERT INTO exchange_rates (source, target, rate, rate_dir) VALUES (%s, %s, %s, %s)", [self.id, target.id, rate, "source_to_target"]) + self.db.execute("SELECT public.update_currency_sell_rate(%s, %s, %s)", + [self.id, target.id, rate]) + def update_buy_rate(self, source, rate): + # self.db.execute("INSERT INTO exchange_rates (source, target, rate, rate_dir) VALUES (%s, %s, %s, %s)", [source.id, self.id, rate, "target_to_source"]) + self.db.execute("SELECT public.update_currency_buy_rate(%s, %s, %s)", + [source.id, self.id, rate]) diff --git a/brmbar3/brmbar/Database.py b/brmbar3/brmbar/Database.py new file mode 100644 index 0000000..d0202d4 --- /dev/null +++ b/brmbar3/brmbar/Database.py @@ -0,0 +1,61 @@ +"""self-reconnecting database object""" + +import psycopg2 +from contextlib import closing +import time + +class Database: + """self-reconnecting database object""" + def __init__(self, dsn): + self.db_conn = psycopg2.connect(dsn) + self.dsn = dsn + + def execute(self, query, attrs = None): + """execute a query and return one result""" + with closing(self.db_conn.cursor()) as cur: + cur = self._execute(cur, query, attrs) + + def execute_and_fetch(self, query, attrs = None): + """execute a query and return one result""" + with closing(self.db_conn.cursor()) as cur: + cur = self._execute(cur, query, attrs) + return cur.fetchone() + + def execute_and_fetchall(self, query, attrs = None): + """execute a query and return all results""" + with closing(self.db_conn.cursor()) as cur: + cur = self._execute(cur, query, attrs) + return cur.fetchall() + + def _execute(self, cur, query, attrs, level=1): + """execute a query, and in case of OperationalError (db restart) + reconnect to database. Recurses with increasig pause between tries""" + try: + if attrs is None: + cur.execute(query) + else: + cur.execute(query, attrs) + return cur + except psycopg2.DataError as error: # when biitr comes and enters '99999999999999999999' for amount + print("We have invalid input data (SQLi?): level %s (%s) @%s" % ( + level, error, time.strftime("%Y%m%d %a %I:%m %p") + )) + self.db_conn.rollback() + raise RuntimeError("Unsanitized data entered again... BOBBY TABLES") + except psycopg2.OperationalError as error: + print("Sleeping: level %s (%s) @%s" % ( + level, error, time.strftime("%Y%m%d %a %I:%m %p") + )) + #TODO: emit message "db conn failed, reconnecting + time.sleep(2 ** level) + try: + self.db_conn = psycopg2.connect(self.dsn) + except psycopg2.OperationalError: + #TODO: emit message "psql not running to interface + time.sleep(1) + cur = self.db_conn.cursor() #how ugly is this? + return self._execute(cur, query, attrs, level+1) + + def commit(self): + """passes commit to db""" + self.db_conn.commit() diff --git a/brmbar3/brmbar/Shop.py b/brmbar3/brmbar/Shop.py index 211d20f..e7dbc6c 100644 --- a/brmbar3/brmbar/Shop.py +++ b/brmbar3/brmbar/Shop.py @@ -2,131 +2,295 @@ import brmbar from .Currency import Currency from .Account import Account -import psycopg2 -from contextlib import closing - class Shop: - """ BrmBar Shop + """ BrmBar Shop - Business logic so that only interaction is left in the hands - of the frontend scripts. """ - def __init__(self, db, currency, profits, cash): - self.db = db - self.currency = currency # brmbar.Currency - self.profits = profits # income brmbar.Account for brmbar profit margins on items - self.cash = cash # our operational ("wallet") cash account + Business logic so that only interaction is left in the hands + of the frontend scripts. """ + def __init__(self, db, currency, profits, cash, excess, deficit): + self.db = db + self.currency = currency # brmbar.Currency + self.profits = profits # income brmbar.Account for brmbar profit margins on items + self.cash = cash # our operational ("wallet") cash account + self.excess = excess # account from which is deducted cash during inventory item fixing (when system contains less items than is the reality) + self.deficit = deficit # account where is put cash during inventory item fixing (when system contains more items than is the reality) - @classmethod - def new_with_defaults(cls, db): - return cls(db, - currency = Currency.default(db), - profits = Account.load(db, name = "BrmBar Profits"), - cash = Account.load(db, name = "BrmBar Cash")) + @classmethod + def new_with_defaults(cls, db): + return cls(db, + currency = Currency.default(db), + profits = Account.load(db, name = "BrmBar Profits"), + cash = Account.load(db, name = "BrmBar Cash"), + excess = Account.load(db, name = "BrmBar Excess"), + deficit = Account.load(db, name = "BrmBar Deficit")) - def sell(self, item, user, amount = 1): - # Sale: Currency conversion from item currency to shop currency - (buy, sell) = item.currency.rates(self.currency) - cost = amount * sell - profit = amount * (sell - buy) + def sell(self, item, user, amount = 1): + # Call the stored procedure for the sale + cost = self.db.execute_and_fetch( + "SELECT public.sell_item(%s, %s, %s, %s, %s)", + [item.id, amount, user.id, self.currency.id, f"BrmBar sale of {amount}x {item.name} to {user.name}"] + )[0]#[0] - transaction = self._transaction(responsible = user, description = "BrmBar sale of {}x {} to {}".format(amount, item.name, user.name)) - item.credit(transaction, amount, user.name) - user.debit(transaction, cost, item.name) # debit (increase) on a _debt_ account - self.profits.debit(transaction, profit, "Margin on " + item.name) - self.db.commit() + self.db.commit() + return cost + # Sale: Currency conversion from item currency to shop currency + #(buy, sell) = item.currency.rates(self.currency) + #cost = amount * sell + #profit = amount * (sell - buy) - return cost + #transaction = self._transaction(responsible = user, description = "BrmBar sale of {}x {} to {}".format(amount, item.name, user.name)) + #item.credit(transaction, amount, user.name) + #user.debit(transaction, cost, item.name) # debit (increase) on a _debt_ account + #self.profits.debit(transaction, profit, "Margin on " + item.name) + #self.db.commit() + #return cost - def sell_for_cash(self, item, amount = 1): - # Sale: Currency conversion from item currency to shop currency - (buy, sell) = item.currency.rates(self.currency) - cost = amount * sell - profit = amount * (sell - buy) + def sell_for_cash(self, item, amount = 1): + cost = self.db.execute_and_fetch( + "SELECT public.sell_item_for_cash(%s, %s, %s, %s, %s)", + [item.id, amount, user.id, self.currency.id, f"BrmBar sale of {amount}x {item.name} for cash"] + )[0]#[0] - transaction = self._transaction(description = "BrmBar sale of {}x {} for cash".format(amount, item.name)) - item.credit(transaction, amount, "Cash") - self.cash.debit(transaction, cost, item.name) - self.profits.debit(transaction, profit, "Margin on " + item.name) - self.db.commit() + self.db.commit() + return cost + ## Sale: Currency conversion from item currency to shop currency + #(buy, sell) = item.currency.rates(self.currency) + #cost = amount * sell + #profit = amount * (sell - buy) - return cost + #transaction = self._transaction(description = "BrmBar sale of {}x {} for cash".format(amount, item.name)) + #item.credit(transaction, amount, "Cash") + #self.cash.debit(transaction, cost, item.name) + #self.profits.debit(transaction, profit, "Margin on " + item.name) + #self.db.commit() - def add_credit(self, credit, user): - transaction = self._transaction(responsible = user, description = "BrmBar credit replenishment for " + user.name) - self.cash.debit(transaction, credit, user.name) - user.credit(transaction, credit, "Credit replenishment") - self.db.commit() + #return cost - def withdraw_credit(self, credit, user): - transaction = self._transaction(responsible = user, description = "BrmBar credit withdrawal for " + user.name) - self.cash.credit(transaction, credit, user.name) - user.debit(transaction, credit, "Credit withdrawal") - self.db.commit() + def undo_sale(self, item, user, amount = 1): + # Undo sale; rarely needed + #(buy, sell) = item.currency.rates(self.currency) + #cost = amount * sell + #profit = amount * (sell - buy) - def buy_for_cash(self, item, amount = 1): - # Buy: Currency conversion from item currency to shop currency - (buy, sell) = item.currency.rates(self.currency) - cost = amount * buy + #transaction = self._transaction(responsible = user, description = "BrmBar sale UNDO of {}x {} to {}".format(amount, item.name, user.name)) + #item.debit(transaction, amount, user.name + " (sale undo)") + #user.credit(transaction, cost, item.name + " (sale undo)") + #self.profits.credit(transaction, profit, "Margin repaid on " + item.name) + # Call the stored procedure for undoing a sale + cost = self.db.execute_and_fetch( + "SELECT public.undo_sale_of_item(%s, %s, %s, %s)", + [item.id, amount, user.id, user.currency.id, f"BrmBar sale UNDO of {amount}x {item.name} to {user.name}"] + )[0]#[0] - transaction = self._transaction(description = "BrmBar stock replenishment of {}x {} for cash".format(amount, item.name)) - item.debit(transaction, amount, "Cash") - self.cash.credit(transaction, cost, item.name) - self.db.commit() + self.db.commit() - return cost + return cost - def receipt_to_credit(self, user, credit, description): - transaction = self._transaction(responsible = user, description = "Receipt: " + description) - self.profits.credit(transaction, credit, user.name) - user.credit(transaction, credit, "Credit from receipt: " + description) - self.db.commit() + def add_credit(self, credit, user): + self.db.execute_and_fetch( + "SELECT public.add_credit(%s, %s, %s, %s)", + [self.cash.id, credit, user.id, user.name] + ) + self.db.commit() - def _transaction(self, responsible = None, description = None): - with closing(self.db.cursor()) as cur: - cur.execute("INSERT INTO transactions (responsible, description) VALUES (%s, %s) RETURNING id", - [responsible.id if responsible else None, description]) - transaction = cur.fetchone()[0] - return transaction + #transaction = self._transaction(responsible = user, description = "BrmBar credit replenishment for " + user.name) + #self.cash.debit(transaction, credit, user.name) + #user.credit(transaction, credit, "Credit replenishment") + #self.db.commit() - def credit_balance(self): - with closing(self.db.cursor()) as cur: - # We assume all debt accounts share a currency - sumselect = """ - SELECT SUM(ts.amount) - FROM accounts AS a - LEFT JOIN transaction_splits AS ts ON a.id = ts.account - WHERE a.acctype = %s AND ts.side = %s - """ - cur.execute(sumselect, ["debt", 'debit']) - debit = cur.fetchone()[0] or 0 - cur.execute(sumselect, ["debt", 'credit']) - credit = cur.fetchone()[0] or 0 - return debit - credit - def credit_negbalance_str(self): - return self.currency.str(-self.credit_balance()) + def withdraw_credit(self, credit, user): + self.db.execute_and_fetch( + "SELECT public.withdraw_credit(%s, %s, %s, %s)", + [self.cash.id, credit, user.id, user.name] + ) + self.db.commit() + #transaction = self._transaction(responsible = user, description = "BrmBar credit withdrawal for " + user.name) + #self.cash.credit(transaction, credit, user.name) + #user.debit(transaction, credit, "Credit withdrawal") + #self.db.commit() - def inventory_balance(self): - balance = 0 - with closing(self.db.cursor()) as cur: - # Each inventory account has its own currency, - # so we just do this ugly iteration - cur.execute("SELECT id FROM accounts WHERE acctype = %s", ["inventory"]) - for inventory in cur: - invid = inventory[0] - inv = Account.load(self.db, id = invid) - # FIXME: This is not correct as each instance of inventory - # might have been bought for a different price! Therefore, - # we need to replace the command below with a complex SQL - # statement that will... ugh, accounting is hard! - balance += inv.currency.convert(inv.balance(), self.currency) - return balance - def inventory_balance_str(self): - return self.currency.str(self.inventory_balance()) + def transfer_credit(self, userfrom, userto, amount): + self.db.execute_and_fetch( + "SELECT public.transfer_credit(%s, %s, %s, %s)", + [self.cash.id, credit, user.id, user.name] + ) + self.db.commit() + #self.add_credit(amount, userto) + #self.withdraw_credit(amount, userfrom) - def account_list(self, acctype): - accts = [] - with closing(self.db.cursor()) as cur: - cur.execute("SELECT id FROM accounts WHERE acctype = %s ORDER BY name ASC", [acctype]) - for inventory in cur: - accts += [ Account.load(self.db, id = inventory[0]) ] - return accts + def buy_for_cash(self, item, amount = 1): + cost = self.db.execute_and_fetch( + "SELECT public.buy_for_cash(%s, %s, %s, %s, %s)", + [self.cash.id, item.id, amount, self.currency.id, item.name] + )[0] + # Buy: Currency conversion from item currency to shop currency + #(buy, sell) = item.currency.rates(self.currency) + #cost = amount * buy + + #transaction = self._transaction(description = "BrmBar stock replenishment of {}x {} for cash".format(amount, item.name)) + #item.debit(transaction, amount, "Cash") + #self.cash.credit(transaction, cost, item.name) + self.db.commit() + + return cost + + def receipt_to_credit(self, user, credit, description): + #transaction = self._transaction(responsible = user, description = "Receipt: " + description) + #self.profits.credit(transaction, credit, user.name) + #user.credit(transaction, credit, "Credit from receipt: " + description) + self.db.execute_and_fetch( + "SELECT public.buy_for_cash(%s, %s, %s, %s, %s)", + [self.profits.id, user.id, user.name, credit, description] + )[0] + self.db.commit() + + def _transaction(self, responsible = None, description = None): + transaction = self.db.execute_and_fetch("INSERT INTO transactions (responsible, description) VALUES (%s, %s) RETURNING id", + [responsible.id if responsible else None, description]) + transaction = transaction[0] + return transaction + + def credit_balance(self, overflow=None): + # We assume all debt accounts share a currency + sumselect = """ + SELECT SUM(ts.amount) + FROM accounts AS a + LEFT JOIN transaction_splits AS ts ON a.id = ts.account + WHERE a.acctype = %s AND ts.side = %s + """ + if overflow is not None: + sumselect += ' AND a.name ' + ('NOT ' if overflow == 'exclude' else '') + ' LIKE \'%%-overflow\'' + cur = self.db.execute_and_fetch(sumselect, ["debt", 'debit']) + debit = cur[0] or 0 + credit = self.db.execute_and_fetch(sumselect, ["debt", 'credit']) + credit = credit[0] or 0 + return debit - credit + def credit_negbalance_str(self, overflow=None): + return self.currency.str(-self.credit_balance(overflow=overflow)) + +# XXX causing extra heavy delay ( thousands of extra SQL queries ), disabled + def inventory_balance(self): + balance = 0 + # Each inventory account has its own currency, + # so we just do this ugly iteration + cur = self.db.execute_and_fetchall("SELECT id FROM accounts WHERE acctype = %s", ["inventory"]) + for inventory in cur: + invid = inventory[0] + inv = Account.load(self.db, id = invid) + # FIXME: This is not correct as each instance of inventory + # might have been bought for a different price! Therefore, + # we need to replace the command below with a complex SQL + # statement that will... ugh, accounting is hard! + b = inv.balance() * inv.currency.rates(self.currency)[0] + # if b != 0: + # print(str(b) + ',' + inv.name) + balance += b + return balance + +# XXX bypass hack + def inventory_balance_str(self): + # return self.currency.str(self.inventory_balance()) + return "XXX" + + def account_list(self, acctype, like_str="%%"): + """list all accounts (people or items, as per acctype)""" + accts = [] + cur = self.db.execute_and_fetchall("SELECT id FROM accounts WHERE acctype = %s AND name ILIKE %s ORDER BY name ASC", [acctype, like_str]) + #FIXME: sanitize input like_str ^ + for inventory in cur: + accts += [ Account.load(self.db, id = inventory[0]) ] + return accts + + def fix_inventory(self, item, amount): + rv = self.db.execute_and_fetch( + "SELECT public.fix_inventory(%s, %s, %s, %s, %s, %s)", + [item.id, item.currency.id, self.excess.id, self.deficit.id, self.currency.id, amount] + )[0] + + self.db.commit() + return rv + #amount_in_reality = amount + #amount_in_system = item.balance() + #(buy, sell) = item.currency.rates(self.currency) + + #diff = abs(amount_in_reality - amount_in_system) + #buy_total = buy * diff + #if amount_in_reality > amount_in_system: + # transaction = self._transaction(description = "BrmBar inventory fix of {}pcs {} in system to {}pcs in reality".format(amount_in_system, item.name,amount_in_reality)) + # item.debit(transaction, diff, "Inventory fix excess") + # self.excess.credit(transaction, buy_total, "Inventory fix excess " + item.name) + # self.db.commit() + # return True + #elif amount_in_reality < amount_in_system: + # transaction = self._transaction(description = "BrmBar inventory fix of {}pcs {} in system to {}pcs in reality".format(amount_in_system, item.name,amount_in_reality)) + # item.credit(transaction, diff, "Inventory fix deficit") + # self.deficit.debit(transaction, buy_total, "Inventory fix deficit " + item.name) + # self.db.commit() + # return True + #else: + # transaction = self._transaction(description = "BrmBar inventory fix of {}pcs {} in system to {}pcs in reality".format(amount_in_system, item.name,amount_in_reality)) + # item.debit(transaction, 0, "Inventory fix - amount was correct") + # item.credit(transaction, 0, "Inventory fix - amount was correct") + # self.db.commit() + # return False + + def fix_cash(self, amount): + rv = self.db.execute_and_fetch( + "SELECT public.fix_cash(%s, %s, %s, %s)", + [self.excess.id, self.deficit.id, self.currency.id, amount] + )[0] + + self.db.commit() + return rv + #amount_in_reality = amount + #amount_in_system = self.cash.balance() + + #diff = abs(amount_in_reality - amount_in_system) + #if amount_in_reality > amount_in_system: + # transaction = self._transaction(description = "BrmBar cash inventory fix of {} in system to {} in reality".format(amount_in_system, amount_in_reality)) + # self.cash.debit(transaction, diff, "Inventory fix excess") + # self.excess.credit(transaction, diff, "Inventory cash fix excess.") + # self.db.commit() + # return True + #elif amount_in_reality < amount_in_system: + # transaction = self._transaction(description = "BrmBar cash inventory fix of {} in system to {} in reality".format(amount_in_system, amount_in_reality)) + # self.cash.credit(transaction, diff, "Inventory fix deficit") + # self.deficit.debit(transaction, diff, "Inventory fix deficit.") + # self.db.commit() + # return True + #else: + # return False + + def consolidate(self): + msg = self.db.execute_and_fetch( + "SELECT public.make_consolidate_transaction(%s, %s, %s)", + [self.excess.id, self.deficit.id, self.profits.id] + )[0] + #transaction = self._transaction(description = "BrmBar inventory consolidation") + #excess_balance = self.excess.balance() + #if excess_balance != 0: + # print("Excess balance {} debited to profit".format(-excess_balance)) + # self.excess.debit(transaction, -excess_balance, "Excess balance added to profit.") + # self.profits.debit(transaction, -excess_balance, "Excess balance added to profit.") + #deficit_balance = self.deficit.balance() + #if deficit_balance != 0: + # print("Deficit balance {} credited to profit".format(deficit_balance)) + # self.deficit.credit(transaction, deficit_balance, "Deficit balance removed from profit.") + # self.profits.credit(transaction, deficit_balance, "Deficit balance removed from profit.") + if msg != None: + print(msg) + self.db.commit() + + def undo(self, oldtid): + #description = self.db.execute_and_fetch("SELECT description FROM transactions WHERE id = %s", [oldtid])[0] + #description = 'undo %d (%s)' % (oldtid, description) + + #transaction = self._transaction(description=description) + #for split in self.db.execute_and_fetchall("SELECT id, side, account, amount, memo FROM transaction_splits WHERE transaction = %s", [oldtid]): + # splitid, side, account, amount, memo = split + # memo = 'undo %d (%s)' % (splitid, memo) + # amount = -amount + # self.db.execute("INSERT INTO transaction_splits (transaction, side, account, amount, memo) VALUES (%s, %s, %s, %s, %s)", [transaction, side, account, amount, memo]) + transaction = self.db.execute_and_fetch("SELECT public.undo_transaction(%s)",[oldtid])[0] + self.db.commit() + return transaction diff --git a/brmbar3/crontab b/brmbar3/crontab new file mode 100644 index 0000000..2e66748 --- /dev/null +++ b/brmbar3/crontab @@ -0,0 +1,15 @@ +# cleanup bounty +*/5 * * * * ~/brmbar/brmbar3/uklid-watchdog.sh +0 0 * * 1 ~/brmbar/brmbar3/uklid-refill.sh +# overall summary +5 4 * * * ~/brmbar/brmbar3/daily-summary.sh | mail -s "daily brmbar summary" yyy@yyy +# debt track +5 0 * * * ~/brmbar/brmbar3/dluhy.sh 2>/dev/null + +# per-user summary +1 0 * * * /home/brmlab/brmbar/brmbar3/log.sh yyy yyy@yyy + +# backup +6 * * * * echo "SELECT * FROM account_balances;" | psql brmbar | gzip -9 | ssh -Tp 110 -i /home/brmlab/.ssh/id_ecdsa jenda@coralmyn.hrach.eu +16 1 * * * pg_dump brmbar | gzip -9 | ssh -Tp 110 -i /home/brmlab/.ssh/id_ecdsa jenda@coralmyn.hrach.eu + diff --git a/brmbar3/daily-summary.sh b/brmbar3/daily-summary.sh new file mode 100755 index 0000000..2fe58bd --- /dev/null +++ b/brmbar3/daily-summary.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Add a crontab entry like: +# 5 4 * * * ~/brmbar/brmbar3/daily-summary.sh | mail -s "daily brmbar summary" rada@brmlab.cz +cd ~/brmbar/brmbar3 +./brmbar-cli.py stats +echo +echo "Time since last full inventory check: $(echo "select now()-time from transactions where description = 'BrmBar inventory consolidation' order by time desc limit 1;" | psql brmbar | tail -n +3 | head -n 1 | tr -s " ")" +echo +echo "Overflows: $(echo "SELECT name, -crbalance FROM account_balances WHERE name LIKE '%overflow%' AND crbalance != 0 ORDER BY name" | psql brmbar | tail -n +3 | grep '|' | tr -s " " | sed -e "s/ |/:/g" -e "s/$/;/" | tr -d "\n") TOTAL: $(echo "SELECT -SUM(crbalance) FROM account_balances WHERE name LIKE '%overflow%' AND crbalance != 0" | psql brmbar | tail -n +3 | head -n 1 | tr -s " ")" +echo +echo "Club Mate sold in last 24 hours: $(echo "select count(*) from transaction_cashsums where time > now() - '1 day'::INTERVAL and (description like '%Club Mate%' or description like '%granatove mate%')" | psql brmbar | tail -n +3 | head -n 1 | tr -s " ") bottles" diff --git a/brmbar3/dluhy.sh b/brmbar3/dluhy.sh new file mode 100755 index 0000000..61c2a46 --- /dev/null +++ b/brmbar3/dluhy.sh @@ -0,0 +1,3 @@ +p1=`echo -n "brmbar - dluhy: "; echo "SELECT name, crbalance FROM account_balances WHERE acctype = 'debt' AND crbalance < -100 AND name NOT LIKE '%overflow%' AND name NOT LIKE 'sachyo' ORDER BY crbalance ASC" | psql brmbar | tail -n +3 | grep '|' | tr -s " " | sed -e "s/ |/:/g" -e "s/$/;/" | tr -d "\n"` +p2=`echo "SELECT sum(crbalance) FROM account_balances WHERE acctype = 'debt' AND crbalance < 0 AND name NOT LIKE '%overflow%' AND name NOT LIKE 'sachyo'" | psql brmbar | tail -n +3 | head -n 1 | tr -s " "` +echo "$p1 total$p2 Kc. https://www.elektro-obojky.cz/" | ssh -p 110 -i /home/brmlab/.ssh/id_rsa jenda@coralmyn.hrach.eu diff --git a/brmbar3/doc/architecture.md b/brmbar3/doc/architecture.md new file mode 100644 index 0000000..791517f --- /dev/null +++ b/brmbar3/doc/architecture.md @@ -0,0 +1,106 @@ +BrmBar v3 - Architectural Overview +================================== + +BrmBar v3 is written in Python, with the database stored in PostgreSQL +and the primary user interface modelled in QtQuick. All user interfaces +share a common *brmbar* package that provides few Python classes for +manipulation with the base objects. + +Objects and Database Schema +--------------------------- + +### Account ### + +The most essential brmbar object is an Account, which can track +balances of various kinds (described by *acctype* column) in the +classical accounting paradigm: + +* **Cash**: A physical stash of cash. One cash account is created + by default, corresponding to the cash box where people put money + when buying stuff (or depositing money in their user accounts). + Often, that's the only cash account you need. +* **Debt**: Represents brmbar's debt to some person. These accounts + are actually the "user accounts" where people deposit money. When + a deposit of 100 is made, 100 is *subtracted* from the balance, + the balance is -100 and brmbar is in debt of 100 to the user. + When the user buys something for 200, 200 is *added* to the balance, + the balance is 100 and the user is in debt of 100 to the brmbar. + This is correct notation from accounting point of view, but somewhat + confusing for the users, so in the user interface (and crbalance + column of some views), this balance is *negated*! +* **Inventory**: Represents inventory items (e.g. Club Mate bottles). + The account balance represents the quantity of items. +* **Income**: Represents pure income of brmbar, i.e. the profit; + there is usually just a single account of this type where all the + profit (sell price of an item minus the buy price of an item) + is accumulated. +* **Expense**: This type is currently not used. +* **Starting balance** and **ending balance**: This may be used + in the future when transaction book needs to be compressed. + +As you can see, the amount of cash, user accounts, inventory items +etc. are all represented as **Account** objects that are of various +**types**, are **named** and have a certain balance (calculated +from a transaction book). That balance is a number represented +in certain **currency**. It also has a set of **barcodes** associated. + +### Currency, Exchange rate ### + +Usually, all accounts that deal with cash (the cash, debt, income, ... +accounts) share a single currency that corresponds to the physical +currency locally in use (the default is `Kč`). However, inventory +items have balances corresponding to item quantities - to deal with +this correctly, each inventory item *has its own currency*; i.e. +`Club Mate` bottle is a currency associated with the `Club Mate` +account. + +Currencies have defined (uni-directional) exchange rates. The exchange +rate of "Kč to Club Mate bottles" is the buy price of Club Mate, how +much you pay for one bottle of Club Mate from the cash box when you +are stocking in Club Mate. The exchange rate of "Club Mate bottle to Kč" +is the sell price of Club Mate, how much you pay for one bottle of Club +Mate to the cash box when you are buying it from brmbar (sell price +should be higher than buy price if you want to make a profit). + +Exchange rate is valid since some defined time; historical exchange +rates are therefore kept and this allows to account for changing prices +of inventory items. (Unfortunately, at the time of writing this, the +profit calculation actually didn't make use of that yet.) + +### Transactions, Transaction splits ### + +A transaction book is used to determine current account balances and +stores all operations related to accounts - depositing or withdrawing +money, stocking in items, and most importantly buying stuff (either for +cash or from a debt account). A transaction happenned at some **time** +and was performed by certain **responsible** person. + +The actual accounts involved in a transaction are specified by a list of +transaction splits that either put balance into the transaction (*credit* +side) or grab balance from it (*debit* side). For example, a typical +transaction representing a sale of Club Mate bottle to user "pasky" +would be split like this: + +* *credit* of 1 Club Mate on Club Mate account with memo "pasky". +* *debit* of 35 Kč on "pasky" account with memo "Club Mate" + (indeed we _add_ 35Kč to the debt account for pasky buying + the Club Mate; if this seems weird, refer to the "debt" account + type description). +* *debit* of 5 Kč on income account Profits with memo "Margin + on Club Mate" (this represents the sale price - buy price delta, + i.e. the profit we made in brmbar by selling this Club Mate). + +The brmbar Python Package +------------------------- + +The **brmbar** package (in brmbar/ subdirectory) provides common brmbar +functionality for the various user interfaces: + +* **Database**: Layer for performing SQL queries with some error handling. +* **Currency**: Class for querying and manipulating currency objects and + converting between them based on stored exchange rates. +* **Account**: Class for querying and manipulating the account objects + and their current balance. +* **Shop**: Class providing the "business logic" of all the actual user + operations: selling stuff, depositing and withdrawing moeny, adding + stock, retrieving list of accounts of given type, etc. diff --git a/brmbar3/import/import-items.pl b/brmbar3/import/import-items.pl new file mode 100644 index 0000000..d0181a9 --- /dev/null +++ b/brmbar3/import/import-items.pl @@ -0,0 +1,15 @@ +# stdin in format: 8594002931643;Brmburky - hov.na cibul;-15 +use v5.12; +while (<>) { + chomp; + say STDERR "--- $_"; + my ($barcode, $name, $price) = split(/;/); + $price = -$price; + print </dev/null || echo -n 0`; + my $side = 'credit'; + if ($balance < 0) { + $balance = -$balance; + $side = 'debit'; + } + print < +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +-- Privileged schema with protected data +CREATE SCHEMA IF NOT EXISTS brmbar_privileged; + +-- Initial versioning +CREATE TABLE IF NOT EXISTS brmbar_privileged.brmbar_schema( + ver INTEGER NOT NULL +); + +-- ---------------------------------------------------------------- +-- Legacy Schema Initialization +-- ---------------------------------------------------------------- + +DO $$ +DECLARE v INTEGER; +BEGIN + SELECT ver FROM brmbar_privileged.brmbar_schema INTO v; + IF v IS NULL THEN + -- -------------------------------- + -- Legacy Types + + SELECT COUNT(*) INTO v + FROM pg_catalog.pg_type typ + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = 'public' + AND typ.typname='exchange_rate_direction'; + IF v=0 THEN + RAISE NOTICE 'Creating type exchange_rate_direction'; + CREATE TYPE public.exchange_rate_direction + AS ENUM ('source_to_target', 'target_to_source'); + ELSE + RAISE NOTICE 'Type exchange_rate_direction already exists'; + END IF; + + SELECT COUNT(*) INTO v + FROM pg_catalog.pg_type typ + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = 'public' + AND typ.typname='account_type'; + IF v=0 THEN + RAISE NOTICE 'Creating type account_type'; + CREATE TYPE public.account_type + AS ENUM ('cash', 'debt', 'inventory', 'income', 'expense', + 'starting_balance', 'ending_balance'); + ELSE + RAISE NOTICE 'Type account_type already exists'; + END IF; + + SELECT COUNT(*) INTO v + FROM pg_catalog.pg_type typ + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = 'public' + AND typ.typname='transaction_split_side'; + IF v=0 THEN + RAISE NOTICE 'Creating type transaction_split_side'; + CREATE TYPE public.transaction_split_side + AS ENUM ('credit', 'debit'); + ELSE + RAISE NOTICE 'Type transaction_split_side already exists'; + END IF; + + -- -------------------------------- + -- Currencies sequence, table and potential initial data + + CREATE SEQUENCE IF NOT EXISTS public.currencies_id_seq + START WITH 2 INCREMENT BY 1; + CREATE TABLE IF NOT EXISTS public.currencies ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('public.currencies_id_seq'::regclass), + name VARCHAR(128) NOT NULL, + UNIQUE(name) + ); + INSERT INTO public.currencies (id, name) VALUES (1, 'Kč') + ON CONFLICT DO NOTHING; + + -- -------------------------------- + -- Exchange rates table - no initial data required + + CREATE TABLE IF NOT EXISTS public.exchange_rates ( + valid_since TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + + target INTEGER NOT NULL, + FOREIGN KEY (target) REFERENCES public.currencies (id), + + source INTEGER NOT NULL, + FOREIGN KEY (source) REFERENCES public.currencies (id), + + rate DECIMAL(12,2) NOT NULL, + rate_dir public.exchange_rate_direction NOT NULL + ); + + -- -------------------------------- + -- Accounts sequence and table and 4 initial accounts + + CREATE SEQUENCE IF NOT EXISTS public.accounts_id_seq + START WITH 2 INCREMENT BY 1; + CREATE TABLE IF NOT EXISTS public.accounts ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('public.accounts_id_seq'::regclass), + + name VARCHAR(128) NOT NULL, + UNIQUE (name), + + currency INTEGER NOT NULL, + FOREIGN KEY (currency) REFERENCES public.currencies (id), + + acctype public.account_type NOT NULL, + + active BOOLEAN NOT NULL DEFAULT TRUE + ); + INSERT INTO public.accounts (id, name, currency, acctype) + VALUES (1, 'BrmBar Cash', (SELECT id FROM public.currencies WHERE name='Kč'), 'cash') + ON CONFLICT DO NOTHING; + INSERT INTO public.accounts (name, currency, acctype) + VALUES ('BrmBar Profits', (SELECT id FROM public.currencies WHERE name='Kč'), 'income') + ON CONFLICT DO NOTHING; + INSERT INTO public.accounts (name, currency, acctype) + VALUES ('BrmBar Excess', (SELECT id FROM public.currencies WHERE name='Kč'), 'income') + ON CONFLICT DO NOTHING; + INSERT INTO public.accounts (name, currency, acctype) + VALUES ('BrmBar Deficit', (SELECT id FROM public.currencies WHERE name='Kč'), 'expense') + ON CONFLICT DO NOTHING; + + -- -------------------------------- + -- Barcodes + + CREATE TABLE IF NOT EXISTS public.barcodes ( + barcode VARCHAR(128) PRIMARY KEY NOT NULL, + + account INTEGER NOT NULL, + FOREIGN KEY (account) REFERENCES public.accounts (id) + ); + INSERT INTO public.barcodes (barcode, account) + VALUES ('_cash_', (SELECT id FROM public.accounts WHERE acctype = 'cash')) + ON CONFLICT DO NOTHING; + + -- -------------------------------- + -- Transactions + + CREATE SEQUENCE IF NOT EXISTS public.transactions_id_seq + START WITH 1 INCREMENT BY 1; + CREATE TABLE IF NOT EXISTS public.transactions ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('public.transactions_id_seq'::regclass), + time TIMESTAMP DEFAULT NOW() NOT NULL, + + responsible INTEGER, + FOREIGN KEY (responsible) REFERENCES public.accounts (id), + + description TEXT + ); + + -- -------------------------------- + -- Transaction splits + + CREATE SEQUENCE IF NOT EXISTS public.transaction_splits_id_seq + START WITH 1 INCREMENT BY 1; + CREATE TABLE IF NOT EXISTS public.transaction_splits ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('public.transaction_splits_id_seq'::regclass), + + transaction INTEGER NOT NULL, + FOREIGN KEY (transaction) REFERENCES public.transactions (id), + + side public.transaction_split_side NOT NULL, + + account INTEGER NOT NULL, + FOREIGN KEY (account) REFERENCES public.accounts (id), + amount DECIMAL(12,2) NOT NULL, + + memo TEXT + ); + + -- -------------------------------- + -- Account balances view + + CREATE OR REPLACE VIEW public.account_balances AS + SELECT ts.account AS id, + accounts.name, + accounts.acctype, + - sum( + CASE + WHEN ts.side = 'credit'::public.transaction_split_side THEN - ts.amount + ELSE ts.amount + END) AS crbalance + FROM public.transaction_splits ts + LEFT JOIN public.accounts ON accounts.id = ts.account + GROUP BY ts.account, accounts.name, accounts.acctype + ORDER BY (- sum( + CASE + WHEN ts.side = 'credit'::public.transaction_split_side THEN - ts.amount + ELSE ts.amount + END)); + + -- -------------------------------- + -- Transaction nice splits view + + CREATE OR REPLACE VIEW public.transaction_nicesplits AS + SELECT ts.id, + ts.transaction, + ts.account, + CASE + WHEN ts.side = 'credit'::public.transaction_split_side THEN - ts.amount + ELSE ts.amount + END AS amount, + a.currency, + ts.memo + FROM public.transaction_splits ts + LEFT JOIN public.accounts a ON a.id = ts.account + ORDER BY ts.id; + + -- -------------------------------- + -- Transaction cash sums view + + CREATE OR REPLACE VIEW public.transaction_cashsums AS + SELECT t.id, + t."time", + sum(credit.credit_cash) AS cash_credit, + sum(debit.debit_cash) AS cash_debit, + a.name AS responsible, + t.description + FROM public.transactions t + LEFT JOIN ( SELECT cts.amount AS credit_cash, + cts.transaction AS cts_t + FROM public.transaction_nicesplits cts + LEFT JOIN public.accounts a_1 ON a_1.id = cts.account OR a_1.id = cts.account + WHERE a_1.currency = (( SELECT accounts.currency + FROM public.accounts + WHERE accounts.name::text = 'BrmBar Cash'::text)) + AND (a_1.acctype = ANY (ARRAY['cash'::public.account_type, 'debt'::public.account_type])) + AND cts.amount < 0::numeric) credit ON credit.cts_t = t.id + LEFT JOIN ( SELECT dts.amount AS debit_cash, + dts.transaction AS dts_t + FROM public.transaction_nicesplits dts + LEFT JOIN public.accounts a_1 ON a_1.id = dts.account OR a_1.id = dts.account + WHERE a_1.currency = (( SELECT accounts.currency + FROM public.accounts + WHERE accounts.name::text = 'BrmBar Cash'::text)) + AND (a_1.acctype = ANY (ARRAY['cash'::public.account_type, 'debt'::public.account_type])) + AND dts.amount > 0::numeric) debit ON debit.dts_t = t.id + LEFT JOIN public.accounts a ON a.id = t.responsible + GROUP BY t.id, a.name + ORDER BY t.id DESC; + + -- -------------------------------- + -- Function to check schema version (used in migrations) + + CREATE OR REPLACE FUNCTION brmbar_privileged.has_exact_schema_version( + IN i_ver INTEGER + ) RETURNS BOOLEAN + VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $x$ + DECLARE + v_ver INTEGER; + BEGIN + SELECT ver INTO v_ver FROM brmbar_privileged.brmbar_schema; + IF v_ver is NULL THEN + RETURN false; + ELSE + RETURN v_ver = i_ver; + END IF; + END; + $x$; + + -- -------------------------------- + -- + + CREATE OR REPLACE FUNCTION brmbar_privileged.upgrade_schema_version_to( + IN i_ver INTEGER + ) RETURNS VOID + VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $x$ + DECLARE + v_ver INTEGER; + BEGIN + SELECT ver FROM brmbar_privileged.brmbar_schema INTO v_ver; + IF v_ver=(i_ver-1) THEN + UPDATE brmbar_privileged.brmbar_schema SET ver = i_ver; + ELSE + RAISE EXCEPTION 'Invalid brmbar schema version transition (% -> %)', v_ver, i_ver; + END IF; + END; + $x$; + + -- Initialize version 1 + INSERT INTO brmbar_privileged.brmbar_schema(ver) VALUES(1); + END IF; +END; +$$; diff --git a/brmbar3/schema/0002-trading-accounts.sql b/brmbar3/schema/0002-trading-accounts.sql new file mode 100644 index 0000000..021df3d --- /dev/null +++ b/brmbar3/schema/0002-trading-accounts.sql @@ -0,0 +1,40 @@ +-- +-- 0002-trading-accounts.sql +-- +-- #2 - add trading accounts to account type type +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- Dominik Pantůček +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- Require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(1) THEN + + ALTER TYPE public.account_type ADD VALUE 'trading'; + + PERFORM brmbar_privileged.upgrade_schema_version_to(2); +END IF; + +END; +$upgrade_block$; diff --git a/brmbar3/schema/0003-new-account.sql b/brmbar3/schema/0003-new-account.sql new file mode 100644 index 0000000..9ac02b5 --- /dev/null +++ b/brmbar3/schema/0003-new-account.sql @@ -0,0 +1,52 @@ +-- +-- 0003-new-account.sql +-- +-- #3 - stored procedure for creating new account +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- Dominik Pantůček +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- Require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(2) THEN + + CREATE OR REPLACE FUNCTION public.create_account( + IN i_name public.accounts.name%TYPE, + IN i_currency public.accounts.currency%TYPE, + IN i_acctype public.accounts.acctype%TYPE + ) RETURNS INTEGER LANGUAGE plpgsql AS $$ + DECLARE + r_id INTEGER; + BEGIN + INSERT INTO public.accounts (name, currency, acctype) + VALUES (i_name, i_currency, i_acctype) RETURNING id INTO r_id; + RETURN r_id; + END + $$; + + PERFORM brmbar_privileged.upgrade_schema_version_to(3); +END IF; + +END; +$upgrade_block$; diff --git a/brmbar3/schema/0004-add-account-barcode.sql b/brmbar3/schema/0004-add-account-barcode.sql new file mode 100644 index 0000000..dbdba9a --- /dev/null +++ b/brmbar3/schema/0004-add-account-barcode.sql @@ -0,0 +1,50 @@ +-- +-- 0004-add-account-barcode.sql +-- +-- #4 - stored procedure for adding barcode to account +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- Dominik Pantůček +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- Require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(3) THEN + + CREATE OR REPLACE FUNCTION public.add_barcode_to_account( + IN i_account public.barcodes.account%TYPE, + IN i_barcode public.barcodes.barcode%TYPE + ) RETURNS VOID LANGUAGE plpgsql AS $$ + DECLARE + r_id INTEGER; + BEGIN + INSERT INTO public.barcodes (account, barcode) + VALUES (i_account, i_barcode); + END + $$; + + PERFORM brmbar_privileged.upgrade_schema_version_to(4); +END IF; + +END; +$upgrade_block$; diff --git a/brmbar3/schema/0005-rename-account.sql b/brmbar3/schema/0005-rename-account.sql new file mode 100644 index 0000000..a957029 --- /dev/null +++ b/brmbar3/schema/0005-rename-account.sql @@ -0,0 +1,51 @@ +-- +-- 0005-rename-account.sql +-- +-- #5 - stored procedure for renaming account +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- Dominik Pantůček +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- Require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(4) THEN + + CREATE OR REPLACE FUNCTION public.rename_account( + IN i_account public.accounts.id%TYPE, + IN i_name public.accounts.name%TYPE + ) RETURNS VOID LANGUAGE plpgsql AS $$ + DECLARE + r_id INTEGER; + BEGIN + UPDATE public.accounts + SET name = i_name + WHERE id = i_account; + END + $$; + + PERFORM brmbar_privileged.upgrade_schema_version_to(5); +END IF; + +END; +$upgrade_block$; diff --git a/brmbar3/schema/0006-new-currency.sql b/brmbar3/schema/0006-new-currency.sql new file mode 100644 index 0000000..0ed9a94 --- /dev/null +++ b/brmbar3/schema/0006-new-currency.sql @@ -0,0 +1,50 @@ +-- +-- 0006-new-currency.sql +-- +-- #6 - stored procedure for creating new currency +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- Dominik Pantůček +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- Require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(5) THEN + + CREATE OR REPLACE FUNCTION public.create_currency( + IN i_name public.currencies.name%TYPE + ) RETURNS INTEGER LANGUAGE plpgsql AS $$ + DECLARE + r_id INTEGER; + BEGIN + INSERT INTO public.currencies (name) + VALUES (i_name) RETURNING id INTO r_id; + RETURN r_id; + END + $$; + + PERFORM brmbar_privileged.upgrade_schema_version_to(6); +END IF; + +END; +$upgrade_block$; diff --git a/brmbar3/schema/0007-update-currency-sell-rate.sql b/brmbar3/schema/0007-update-currency-sell-rate.sql new file mode 100644 index 0000000..f627897 --- /dev/null +++ b/brmbar3/schema/0007-update-currency-sell-rate.sql @@ -0,0 +1,49 @@ +-- +-- 0007-update-currency-sell-rate.sql +-- +-- #7 - stored procedure for updating sell rate +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- Dominik Pantůček +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- Require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(6) THEN + + CREATE OR REPLACE FUNCTION public.update_currency_sell_rate( + IN i_currency public.exchange_rates.source%TYPE, + IN i_target public.exchange_rates.target%TYPE, + IN i_rate public.exchange_rates.rate%TYPE + ) RETURNS VOID LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO public.exchange_rates(source, target, rate, rate_dir) + VALUES (i_currency, i_target, i_rate, 'source_to_target'); + END + $$; + + PERFORM brmbar_privileged.upgrade_schema_version_to(7); +END IF; + +END; +$upgrade_block$; diff --git a/brmbar3/schema/0008-update-currency-buy-rate.sql b/brmbar3/schema/0008-update-currency-buy-rate.sql new file mode 100644 index 0000000..cbab11b --- /dev/null +++ b/brmbar3/schema/0008-update-currency-buy-rate.sql @@ -0,0 +1,49 @@ +-- +-- 0008-update-currency-buy-rate.sql +-- +-- #8 - stored procedure for updating buy rate +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- Dominik Pantůček +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- Require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(7) THEN + + CREATE OR REPLACE FUNCTION public.update_currency_buy_rate( + IN i_currency public.exchange_rates.target%TYPE, + IN i_source public.exchange_rates.source%TYPE, + IN i_rate public.exchange_rates.rate%TYPE + ) RETURNS VOID LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO public.exchange_rates(source, target, rate, rate_dir) + VALUES (i_source, i_currency, i_rate, 'target_to_source'); + END + $$; + + PERFORM brmbar_privileged.upgrade_schema_version_to(8); +END IF; + +END; +$upgrade_block$; diff --git a/brmbar3/schema/0009-shop-sell.sql b/brmbar3/schema/0009-shop-sell.sql new file mode 100644 index 0000000..811a5f5 --- /dev/null +++ b/brmbar3/schema/0009-shop-sell.sql @@ -0,0 +1,149 @@ +-- +-- 0009-shop-sell.sql +-- +-- #9 - stored function for sell transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(8) THEN + +-- return negative number on rate not found +CREATE OR REPLACE FUNCTION public.find_buy_rate( + IN i_item_id public.accounts.id%TYPE; + IN i_other_id public.accounts.id%TYPE; +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +DECLARE + v_rate public.exchange_rates.rate%TYPE; + v_rate_dir public.exchange_rates.rate_dir%TYPE; +BEGIN + SELECT rate INTO STRICT v_rate, rate_dir INTO STRICT v_rate_dir FROM public.exchange_rates WHERE target = i_item_id AND source = i_other_id AND valid_since <= NOW() ORDER BY valid_since DESC LIMIT 1; + IF v_rate_dir = 'target_to_source'::public.exchange_rate_direction THEN + RETURN v_rate; + ELSE + RETURN 1/v_rate; + END IF; +EXCEPTION + WHEN NO_DATA_FOUND THEN + RETURN -1; +END; +$$ + + +-- return negative number on rate not found +CREATE OR REPLACE FUNCTION public.find_sell_rate( + IN i_item_id public.accounts.id%TYPE; + IN i_other_id public.accounts.id%TYPE; +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +DECLARE + v_rate public.exchange_rates.rate%TYPE; + v_rate_dir public.exchange_rates.rate_dir%TYPE; +BEGIN + SELECT rate INTO STRICT v_rate, rate_dir INTO STRICT v_rate_dir FROM public.exchange_rates WHERE target = i_other_id AND source = i_item_id AND valid_since <= NOW() ORDER BY valid_since DESC LIMIT 1; + IF v_rate_dir = 'source_to_target'::public.exchange_rate_direction THEN + RETURN v_rate; + ELSE + RETURN 1/v_rate; + END IF; +EXCEPTION + WHEN NO_DATA_FOUND THEN + RETURN -1; +END; +$$ + +CREATE OR REPLACE FUNCTION public.create_transaction( + i_responsible_id public.accounts.id%TYPE, + i_description public.transactions.description%TYPE +) RETURNS public.transactions.id%TYPE AS $$ +DECLARE + new_transaction_id public.transactions%TYPE; +BEGIN + -- Create a new transaction + INSERT INTO public.transactions (responsible, description) + VALUES (i_responsible_id, i_description) + RETURNING id INTO new_transaction_id; + -- Return the new transaction ID + RETURN new_transaction_id; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION public.sell_item( + i_item_id public.accounts.id%TYPE, + i_amount INTEGER, + i_user_id public.accounts.id%TYPE, + i_target_currency_id public.currencies.id%TYPE, + i_description TEXT +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +DECLARE + v_buy_rate NUMERIC; + v_sell_rate NUMERIC; + v_cost NUMERIC; + v_profit NUMERIC; + v_transaction_id public.transactions.id%TYPE; +BEGIN + -- Get the buy and sell rates from the stored functions + v_buy_rate := public.find_buy_rate(i_item_id, i_target_currency_id); + v_sell_rate := public.find_sell_rate(i_item_id, i_target_currency_id); + + -- Calculate cost and profit + v_cost := i_amount * v_sell_rate; + v_profit := i_amount * (v_sell_rate - v_buy_rate); + + -- Create a new transaction + v_transaction_id := public.create_transaction(i_user_id, i_description); + + -- the item (decrease stock) + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', i_item_id, i_amount, + (SELECT "name" FROM public.accounts WHERE id = i_user_id)); + + -- the user + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'debit', i_user_id, v_cost, + (SELECT "name" FROM public.accounts WHERE id = i_item_id)); + + -- the profit + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'debit', (SELECT account_id FROM accounts WHERE name = 'BrmBar Profits'), v_profit, (SELECT 'Margin on ' || "name" FROM public.accounts WHERE id = i_item_id)); + + -- Return the cost + RETURN v_cost; +END; +$$; + +PERFORM brmbar_privileged.upgrade_schema_version_to(9); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0010-shop-sell-for-cash.sql b/brmbar3/schema/0010-shop-sell-for-cash.sql new file mode 100644 index 0000000..23ad131 --- /dev/null +++ b/brmbar3/schema/0010-shop-sell-for-cash.sql @@ -0,0 +1,143 @@ +-- +-- 0010-shop-sell-for-cash.sql +-- +-- #10 - stored function for cash sell transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(9) THEN + +CREATE OR REPLACE FUNCTION brmbar_privileged.create_transaction( + i_responsible_id public.accounts.id%TYPE, + i_description public.transactions.description%TYPE +) RETURNS public.transactions.id%TYPE AS $$ +DECLARE + new_transaction_id public.transactions%TYPE; +BEGIN + -- Create a new transaction + INSERT INTO public.transactions (responsible, description) + VALUES (i_responsible_id, i_description) + RETURNING id INTO new_transaction_id; + -- Return the new transaction ID + RETURN new_transaction_id; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION brmbar_privileged.sell_item_internal( + i_item_id public.accounts.id%TYPE, + i_amount INTEGER, + i_user_id public.accounts.id%TYPE, + i_target_currency_id public.currencies.id%TYPE, + i_other_memo TEXT, + i_description TEXT +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +DECLARE + v_buy_rate NUMERIC; + v_sell_rate NUMERIC; + v_cost NUMERIC; + v_profit NUMERIC; + v_transaction_id public.transactions.id%TYPE; +BEGIN + -- Get the buy and sell rates from the stored functions + v_buy_rate := public.find_buy_rate(i_item_id, i_target_currency_id); + v_sell_rate := public.find_sell_rate(i_item_id, i_target_currency_id); + + -- Calculate cost and profit + v_cost := i_amount * v_sell_rate; + v_profit := i_amount * (v_sell_rate - v_buy_rate); + + -- Create a new transaction + v_transaction_id := brmbar_privileged.create_transaction(i_user_id, i_description); + + -- the item (decrease stock) + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', i_item_id, i_amount, + i_other_memo); + + -- the user + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'debit', i_user_id, v_cost, + (SELECT "name" FROM public.accounts WHERE id = i_item_id)); + + -- the profit + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'debit', (SELECT account_id FROM accounts WHERE name = 'BrmBar Profits'), v_profit, (SELECT 'Margin on ' || "name" FROM public.accounts WHERE id = i_item_id)); + + -- Return the cost + RETURN v_cost; +END; +$$; + +CREATE OR REPLACE FUNCTION public.sell_item( + i_item_id public.accounts.id%TYPE, + i_amount INTEGER, + i_user_id public.accounts.id%TYPE, + i_target_currency_id public.currencies.id%TYPE, + i_description TEXT +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN brmbar_privileged.sell_item_internal(i_item_id, + i_amount, + i_other_id, + i_target_currency_id, + (SELECT "name" FROM public.accounts WHERE id = i_user_id), + i_description); +END; +$$; + +CREATE OR REPLACE FUNCTION public.sell_item_for_cash( + i_item_id public.accounts.id%TYPE, + i_amount INTEGER, + i_user_id public.accounts.id%TYPE, + i_target_currency_id public.currencies.id%TYPE, + i_description TEXT +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN brmbar_privileged.sell_item_internal(i_item_id, + i_amount, + i_other_id, + i_target_currency_id, + 'Cash', + i_description); +END; +$$; + +DROP FUNCTION public.create_transaction; + +PERFORM brmbar_privileged.upgrade_schema_version_to(10); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0011-shop-undo-sale.sql b/brmbar3/schema/0011-shop-undo-sale.sql new file mode 100644 index 0000000..67d47cc --- /dev/null +++ b/brmbar3/schema/0011-shop-undo-sale.sql @@ -0,0 +1,102 @@ +-- +-- 0011-shop-undo-sale.sql +-- +-- #11 - stored function for sale undo transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(10) THEN + +CREATE OR REPLACE FUNCTION brmbar_privileged.create_transaction( + i_responsible_id public.accounts.id%TYPE, + i_description public.transactions.description%TYPE +) RETURNS public.transactions.id%TYPE AS $$ +DECLARE + new_transaction_id public.transactions%TYPE; +BEGIN + -- Create a new transaction + INSERT INTO public.transactions (responsible, description) + VALUES (i_responsible_id, i_description) + RETURNING id INTO new_transaction_id; + -- Return the new transaction ID + RETURN new_transaction_id; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION public.undo_sale_of_item( + i_item_id public.accounts.id%TYPE, + i_amount INTEGER, + i_user_id public.accounts.id%TYPE, + i_target_currency_id public.currencies.id%TYPE, + i_description TEXT +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +DECLARE + v_buy_rate NUMERIC; + v_sell_rate NUMERIC; + v_cost NUMERIC; + v_profit NUMERIC; + v_transaction_id public.transactions.id%TYPE; +BEGIN + -- Get the buy and sell rates from the stored functions + v_buy_rate := public.find_buy_rate(i_item_id, i_target_currency_id); + v_sell_rate := public.find_sell_rate(i_item_id, i_target_currency_id); + + -- Calculate cost and profit + v_cost := i_amount * v_sell_rate; + v_profit := i_amount * (v_sell_rate - v_buy_rate); + + -- Create a new transaction + v_transaction_id := brmbar_privileged.create_transaction(i_user_id, i_description); + + -- the item (decrease stock) + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'debit', i_item_id, i_amount, + (SELECT "name" || ' (sale undo)' FROM public.accounts WHERE id = i_user_id)); + + -- the user + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', i_user_id, v_cost, + (SELECT "name" || ' (sale undo)' FROM public.accounts WHERE id = i_item_id)); + + -- the profit + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', (SELECT account_id FROM accounts WHERE name = 'BrmBar Profits'), v_profit, (SELECT 'Margin repaid on ' || "name" FROM public.accounts WHERE id = i_item_id)); + + -- Return the cost + RETURN v_cost; +END; +$$; + +PERFORM brmbar_privileged.upgrade_schema_version_to(11); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0012-shop-add-credit.sql b/brmbar3/schema/0012-shop-add-credit.sql new file mode 100644 index 0000000..00318ae --- /dev/null +++ b/brmbar3/schema/0012-shop-add-credit.sql @@ -0,0 +1,64 @@ +-- +-- 0012-shop-add-credit.sql +-- +-- #12 - stored function for cash deposit transactions +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(11) THEN + +CREATE OR REPLACE FUNCTION public.add_credit( + i_cash_account_id public.accounts.id%TYPE, + i_credit NUMERIC, + i_user_id public.accounts.id%TYPE, + i_user_name TEXT +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_transaction_id public.transactions.id%TYPE; +BEGIN + -- Create a new transaction + v_transaction_id := brmbar_privileged.create_transaction(i_user_id, 'BrmBar credit replenishment for ' || i_user_name); + -- Debit cash (credit replenishment) + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (v_transaction_id, 'debit', i_cash_account_id, i_credit, i_user_name); + -- Credit the user + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (v_transaction_id, 'credit', i_user_id, i_credit, 'Credit replenishment'); +END; +$$; + + + +PERFORM brmbar_privileged.upgrade_schema_version_to(12); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0013-shop-withdraw-credit.sql b/brmbar3/schema/0013-shop-withdraw-credit.sql new file mode 100644 index 0000000..d379186 --- /dev/null +++ b/brmbar3/schema/0013-shop-withdraw-credit.sql @@ -0,0 +1,64 @@ +-- +-- 0013-shop-withdraw-credit.sql +-- +-- #13 - stored function for cash withdrawal transactions +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(12) THEN + +CREATE OR REPLACE FUNCTION public.withdraw_credit( + i_cash_account_id public.accounts.id%TYPE, + i_credit NUMERIC, + i_user_id public.accounts.id%TYPE, + i_user_name TEXT +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_transaction_id public.transactions.id%TYPE; +BEGIN + -- Create a new transaction + v_transaction_id := brmbar_privileged.create_transaction(i_user_id, 'BrmBar credit withdrawal for ' || i_user_name); + -- Debit cash (credit replenishment) + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (v_transaction_id, 'credit', i_cash_account_id, i_credit, i_user_name); + -- Credit the user + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (v_transaction_id, 'debit', i_user_id, i_credit, 'Credit withdrawal'); +END; +$$; + + + +PERFORM brmbar_privileged.upgrade_schema_version_to(13); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0014-shop-transfer-credit.sql b/brmbar3/schema/0014-shop-transfer-credit.sql new file mode 100644 index 0000000..7e13c03 --- /dev/null +++ b/brmbar3/schema/0014-shop-transfer-credit.sql @@ -0,0 +1,58 @@ +-- +-- 0014-shop-transfer-credit.sql +-- +-- #14 - stored function for "credit" transfer transactions +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(13) THEN + +CREATE OR REPLACE FUNCTION public.transfer_credit( + i_cash_account_id public.accounts.id%TYPE, + i_credit NUMERIC, + i_userfrom_id public.accounts.id%TYPE, + i_userfrom_name TEXT, + i_userto_id public.accounts.id%TYPE, + i_userto_name TEXT +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM public.add_credit(i_cash_account_id, i_credit, i_userto_id, i_userto_name); + PERFORM public.withdraw_credit(i_cash_account_id, i_credit, i_userfrom_id, i_userfrom_name); +END; +$$; + + + +PERFORM brmbar_privileged.upgrade_schema_version_to(14); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0015-shop-buy-for-cash.sql b/brmbar3/schema/0015-shop-buy-for-cash.sql new file mode 100644 index 0000000..5cf12bb --- /dev/null +++ b/brmbar3/schema/0015-shop-buy-for-cash.sql @@ -0,0 +1,82 @@ +-- +-- 0015-shop-buy-for-cash.sql +-- +-- #15 - stored function for cash-based stock replenishment transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(14) THEN + +CREATE OR REPLACE FUNCTION public.buy_for_cash( + i_cash_account_id public.accounts.id%TYPE, + i_item_id public.accounts.id%TYPE, + i_amount INTEGER, + i_target_currency_id public.currencies.id%TYPE, + i_item_name TEXT +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +DECLARE + v_buy_rate NUMERIC; + v_cost NUMERIC; + v_transaction_id public.transactions.id%TYPE; +BEGIN + -- this could fail and it would generate exception in python + -- FIXME: convert v_buy_rate < 0 into python exception + v_buy_rate := public.find_buy_rate(i_item_id, i_target_currency_id); + -- this could fail and it would generate exception in python, even though it is not used + --v_sell_rate := public.find_sell_rate(i_item_id, i_target_currency_id); + + -- Calculate cost and profit + v_cost := i_amount * v_buy_rate; + + -- Create a new transaction + v_transaction_id := brmbar_privileged.create_transaction(NULL, + 'BrmBar stock replenishment of ' || i_amount || 'x ' || i_item_name || ' for cash'); + + -- the item + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'debit', i_item_id, i_amount, + 'Cash'); + + -- the cash + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', i_cash_account_id, v_cost, + i_item_name); + + -- Return the cost + RETURN v_cost; +END; +$$; + +PERFORM brmbar_privileged.upgrade_schema_version_to(15); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0016-shop-receipt-to-credit.sql b/brmbar3/schema/0016-shop-receipt-to-credit.sql new file mode 100644 index 0000000..21af8b1 --- /dev/null +++ b/brmbar3/schema/0016-shop-receipt-to-credit.sql @@ -0,0 +1,64 @@ +-- +-- 0016-shop-buy-for-cash.sql +-- +-- #16 - stored function for receipt reimbursement transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(15) THEN + +CREATE OR REPLACE FUNCTION public.receipt_reimbursement( + i_profits_id public.accounts.id%TYPE, + i_user_id public.accounts.id%TYPE, + i_user_name public.accounts.name%TYPE, + i_amount NUMERIC, + i_description TEXT +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_transaction_id public.transactions.id%TYPE; +BEGIN + -- Create a new transaction + v_transaction_id := brmbar_privileged.create_transaction(i_user_id, + 'Receipt: ' || i_description); + -- the "profit" + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', i_profits_id, i_amount, i_user_name); + -- the user + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', i_user_id, i_amount, 'Credit from receipt: ' || i_description); +END; +$$; + +PERFORM brmbar_privileged.upgrade_schema_version_to(16); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0017-shop-fix-inventory.sql b/brmbar3/schema/0017-shop-fix-inventory.sql new file mode 100644 index 0000000..3bacd8d --- /dev/null +++ b/brmbar3/schema/0017-shop-fix-inventory.sql @@ -0,0 +1,157 @@ +-- +-- 0017-shop-fix-inventory.sql +-- +-- #17 - stored function for "fixing" inventory transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(16) THEN + +CREATE OR REPLACE FUNCTION public.compute_account_balance( + i_account_id public.accounts.id%TYPE +) RETURNS NUMERIC +LANGUAGE plpgsql +AS $$ +DECLARE + v_crsum NUMERIC; + v_dbsum NUMERIC; +BEGIN + SELECT COALESCE(SUM(CASE WHEN side='credit' THEN amount ELSE 0 END),0) crsum INTO v_crsum, + COALESCE(SUM(CASE WHEN side='debit' THEN amount ELSE 0 END),0) dbsum into v_dbsum + FROM public.transaction_splits ts WHERE ts.account=4 + RETURN v_dbsum - v_crsum; +END; $$; + +CREATE OR REPLACE FUNCTION brmbar_privileged.fix_account_balance( + IN i_account_id public.acounts.id%TYPE, + IN i_account_currency_id public.currencies.id%TYPE, + IN i_excess_id public.acounts.id%TYPE, + IN i_deficit_id public.acounts.id%TYPE, + IN i_shop_currency_id public.currencies.id%TYPE, + IN i_amount_in_reality NUMERIC +) RETURNS BOOLEAN +VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $fn$ +DECLARE + v_amount_in_system NUMERIC; + v_buy_rate NUMERIC; + v_currency_id public.currencies.id%TYPE; + v_diff NUMERIC; + v_buy_total NUMERIC; + v_ntrn_id public.transactions.id%TYPE; + v_transaction_memo TEXT; + v_item_name TEXT; + v_excess_memo TEXT; + v_deficit_memo TEXT; + + v_old_trn public.transactions%ROWTYPE; + v_old_split public.transaction_splits%ROWTYPE; +BEGIN + v_amount_in_system := public.compute_account_balance(i_account_id); + IF i_account_currency_id <> i_shop_currency_id THEN + v_buy_rate := public.find_buy_rate(i_item_id, i_shop_currency_id); + ELSE + v_buy_rate := 1; + END IF; + + v_diff := ABS(i_amount_in_reality - v_amount_in_system); + v_buy_total := v_buy_rate * v_diff; + -- compute memo strings + IF i_item_id = 1 THEN -- cash account recognized by magic id + -- fixing cash + v_transaction_memo := + 'BrmBar cash inventory fix of ' || v_amount_in_system + || ' in system to ' || i_amount_in_reality || ' in reality'; + v_excess_memo := 'Inventory cash fix excess.'; + v_deficit_memo := 'Inventory fix deficit.'; + ELSE + -- fixing other account + SELECT "name" INTO v_item_name FROM public.accounts WHERE id = i_account_id; + v_transaction_memo := + 'BrmBar inventory fix of ' || v_amount_in_system || 'pcs ' + || v_item_name + || ' in system to ' || i_amount_in_reality || 'pcs in reality'; + v_excess_memo := 'Inventory fix excess ' || v_item_name; + v_deficit_memo := 'Inventory fix deficit ' || v_item_name; + END IF; + -- create transaction based on the relation between counting and accounting + IF i_amount_in_reality > v_amount_in_system THEN + v_ntrn_id := brmbar_privileged.create_transaction(NULL, v_transaction_memo); + INSERT INTO transaction_splits ("transaction", "side", "account", "amount", "memo") + VALUES (v_ntrn_id, 'debit', i_item_id, v_diff, 'Inventory fix excess'); + INSERT INTO transaction_splits ("transaction", "side", "account", "amount", "memo") + VALUES (v_ntrn_id, 'credit', i_excess_id, v_buy_total, v_excess_memo); + RETURN TRUE; + ELSIF i_amount_in_reality < v_amount_in_system THEN + v_ntrn_id := brmbar_privileged.create_transaction(NULL, v_transaction_memo); + INSERT INTO transaction_splits ("transaction", "side", "account", "amount", "memo") + VALUES (v_ntrn_id, 'credit', i_item_id, v_diff, 'Inventory fix deficit'); + INSERT INTO transaction_splits ("transaction", "side", "account", "amount", "memo") + VALUES (v_ntrn_id, 'debit', i_deficit_id, v_buy_total, v_deficit_memo); + RETURN TRUE; + ELSIF i_account_id <> 1 THEN -- cash account recognized by magic id + -- record that everything is going on swimmingly only for noncash accounts (WTF) + v_ntrn_id := brmbar_privileged.create_transaction(NULL, v_transaction_memo); + INSERT INTO transaction_splits ("transaction", "side", "account", "amount", "memo") + VALUES (v_ntrn_id, 'debit', i_item_id, 0, 'Inventory fix - amount was correct'); + INSERT INTO transaction_splits ("transaction", "side", "account", "amount", "memo") + VALUES (v_ntrn_id, 'credit', i_item_id, 0, 'Inventory fix - amount was correct'); + RETURN FALSE; + END IF; + RETURN FALSE; +END; +$fn$; + + +CREATE OR REPLACE FUNCTION public.fix_inventory( + IN i_account_id public.acounts.id%TYPE, + IN i_account_currency_id public.currencies.id%TYPE, + IN i_excess_id public.acounts.id%TYPE, + IN i_deficit_id public.acounts.id%TYPE, + IN i_shop_currency_id public.currencies.id%TYPE, + IN i_amount_in_reality NUMERIC +) RETURNS BOOLEAN +VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $fn$ +BEGIN + RETURN brmbar_privileged.fix_account_balance( + i_account_id, + i_account_currency_id, + i_excess_id, + i_deficit_id, + i_shop_currency_id, + i_amount_in_reality + ); +END; +$fn$; + + +PERFORM brmbar_privileged.upgrade_schema_version_to(17); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0018-shop-fix-cash.sql b/brmbar3/schema/0018-shop-fix-cash.sql new file mode 100644 index 0000000..2af5d72 --- /dev/null +++ b/brmbar3/schema/0018-shop-fix-cash.sql @@ -0,0 +1,60 @@ +-- +-- 0018-shop-fix-cash.sql +-- +-- #18 - stored function for "fixing cash" transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(17) THEN + +CREATE OR REPLACE FUNCTION public.fix_cash( + IN i_excess_id public.acounts.id%TYPE, + IN i_deficit_id public.acounts.id%TYPE, + IN i_shop_currency_id public.currencies.id%TYPE, + IN i_amount_in_reality NUMERIC +) RETURNS BOOLEAN +VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $fn$ +BEGIN + RETURN brmbar_privileged.fix_account_balance( + 1, + 1, + i_excess_id, + i_deficit_id, + i_shop_currency_id, + i_amount_in_reality + ); +END; +$fn$; + + +PERFORM brmbar_privileged.upgrade_schema_version_to(18); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0019-shop-consolidate.sql b/brmbar3/schema/0019-shop-consolidate.sql new file mode 100644 index 0000000..db06685 --- /dev/null +++ b/brmbar3/schema/0019-shop-consolidate.sql @@ -0,0 +1,82 @@ +-- +-- 0019-shop-consolidate.sql +-- +-- #19 - stored function for "consolidation" transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(18) THEN + +CREATE OR REPLACE FUNCTION public.make_consolidate_transaction( + i_excess_id public.accounts.id%TYPE, + i_deficit_id public.accounts.id%TYPE, + i_profits_id public.accounts.id%TYPE +) RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + v_transaction_id public.transactions.id%TYPE; + v_excess_balance NUMERIC; + v_deficit_balance NUMERIC; + v_ret TEXT; +BEGIN + v_ret := NULL; + -- Create a new transaction + v_transaction_id := brmbar_privileged.create_transaction(NULL, + 'BrmBar inventory consolidation'); + v_excess_balance := public.compute_account_balance(i_excess_id); + v_deficit_balance := public.compute_account_balance(i_deficit_id); + IF v_excess_balance <> 0 THEN + v_ret := 'Excess balance ' || -v_excess_balance || ' debited to profit'; + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'debit', i_excess_id, -v_excess_balance, + 'Excess balance added to profit.'); + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'debit', i_profits_id, -v_excess_balance, + 'Excess balance added to profit.'); + END IF; + IF v_deficit_balance <> 0 THEN + v_ret := COALESCE(v_ret, ''); + v_ret := v_ret || 'Deficit balance ' || v_deficit_balance || ' credited to profit'; + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', i_deficit_id, v_deficit_balance, + 'Deficit balance removed from profit.'); + INSERT INTO public.transaction_splits (transaction, side, account, amount, memo) + VALUES (i_transaction_id, 'credit', i_profits_id, v_deficit_balance, + 'Deficit balance removed from profit.'); + END IF; + RETURN v_ret; +END; +$$; + +PERFORM brmbar_privileged.upgrade_schema_version_to(19); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/schema/0020-shop-undo.sql b/brmbar3/schema/0020-shop-undo.sql new file mode 100644 index 0000000..a279580 --- /dev/null +++ b/brmbar3/schema/0020-shop-undo.sql @@ -0,0 +1,64 @@ +-- +-- 0020-shop-undo.sql +-- +-- #20 - stored function for undo transaction +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- TMA +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +DO $upgrade_block$ +BEGIN + +IF brmbar_privileged.has_exact_schema_version(19) THEN + +CREATE OR REPLACE FUNCTION public.undo_transaction( + IN i_id public.transactions.id%TYPE) +RETURNS public.transactions.id%TYPE +VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $fn$ +DECLARE + v_ntrn_id public.transactions.id%TYPE; + v_old_trn public.transactions%ROWTYPE; + v_old_split public.transaction_splits%ROWTYPE; +BEGIN + SELECT * INTO v_old_trn FROM public.transactions WHERE id = i_id; + INSERT INTO transactions ("description") VALUES ('undo '||o_id||' ('||v_old_trn.description||')') RETURNING id into v_ntrn_id; + FOR v_old_split IN + SELECT * FROM transaction_splits WHERE "transaction" = i_id + LOOP + INSERT INTO transaction_splits ("transaction", "side", "account", "amount", "memo") + VALUES (v_ntrn_id, v_old_split.side, v_old_split.account, -v_old_split.amount, + 'undo ' || v_old_split.id || ' (' || v_old_split.memo || ')' ); + END LOOP; + RETURN v_ntrn_id; +END; +$fn$; + + + +PERFORM brmbar_privileged.upgrade_schema_version_to(20); +END IF; + +END; +$upgrade_block$; + +-- vim: set ft=plsql : diff --git a/brmbar3/test--currency-rates.py b/brmbar3/test--currency-rates.py new file mode 100644 index 0000000..9ef93bb --- /dev/null +++ b/brmbar3/test--currency-rates.py @@ -0,0 +1,73 @@ +#!/usr/bin/python3 + +import sys +import subprocess + +#from brmbar import Database +#from brmbar import Currency + +from contextlib import closing +import psycopg2 +from brmbar.Database import Database +from brmbar.Currency import Currency +import math + +#import brmbar + + + +def approx_equal(a, b, tol=1e-6): + """Check if two (buy, sell) rate tuples are approximately equal.""" + return ( + isinstance(a, tuple) and isinstance(b, tuple) and + math.isclose(a[0], b[0], abs_tol=tol) and + math.isclose(a[1], b[1], abs_tol=tol) + ) + +def compare_exceptions(e1, e2): + """Compare exception types and messages.""" + return type(e1) == type(e2) and str(e1) == str(e2) + +def main(): + db = Database("dbname=brmbar") + + # Get all currencies + with closing(db.db_conn.cursor()) as cur: + cur.execute("SELECT id, name FROM currencies") + currencies = cur.fetchall() + + # Build Currency objects + currency_objs = [Currency(db, id, name) for id, name in currencies] + + # Test all currency pairs + for c1 in currency_objs: + for c2 in currency_objs: + #if c1.id == c2.id: + # continue + + try: + rates1 = c1.rates(c2) + exc1 = None + except (RuntimeError, NameError) as e1: + rates1 = None + exc1 = e1 + + try: + rates2 = c1.rates2(c2) + exc2 = None + except (RuntimeError, NameError) as e2: + rates2 = None + exc2 = e2 + + if exc1 or exc2: + if not compare_exceptions(exc1, exc2): + print(f"[EXCEPTION DIFFERENCE] {c1.name} -> {c2.name}") + print(f" rates() exception: {type(exc1).__name__}: {exc1}") + print(f" rates2() exception: {type(exc2).__name__}: {exc2}") + elif not approx_equal(rates1, rates2): + print(f"[VALUE DIFFERENCE] {c1.name} -> {c2.name}") + print(f" rates(): {rates1}") + print(f" rates2(): {rates2}") + +if __name__ == "__main__": + main() diff --git a/brmbar3/uklid-refill.sh b/brmbar3/uklid-refill.sh new file mode 100644 index 0000000..898a081 --- /dev/null +++ b/brmbar3/uklid-refill.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ `./brmbar-cli.py iteminfo uklid|grep -o '[0-9]*.[0-9]* pcs'|cut -d '.' -f 1` -eq 0 ]; then + BOUNTY=`./brmbar-cli.py restock uklid 1 | grep -o 'take -[0-9]*'|grep -o '[0-9]*'` + echo "Brmlab cleanup bounty for ${BOUNTY}CZK!!!"|ssh jenda@fry.hrach.eu +fi diff --git a/brmbar3/uklid-watchdog.sh b/brmbar3/uklid-watchdog.sh new file mode 100644 index 0000000..7f86870 --- /dev/null +++ b/brmbar3/uklid-watchdog.sh @@ -0,0 +1,18 @@ +#!/bin/bash +LASTIDF=/home/brmlab/uklid.last + +LASTID=`cat $LASTIDF 2>/dev/null || echo 0` + + +RES=`psql brmbar -Atq -c "select id,description from transactions where id>$LASTID and description like 'BrmBar sale of 1x uklid%' LIMIT 1;"` +if [ ! -z "$RES" ]; then + LASTID=`echo "$RES"|cut -d '|' -f 1` + echo $LASTID > $LASTIDF + + WINNER=`echo "$RES"|grep -o 'to [^ ]*'|cut -d ' ' -f 2` + if [ -z "$WINNER" ]; then + WINNER="anonymous hunter" + fi + echo "Brmlab cleanup bounty was claimed by $WINNER! Thanks!"|ssh -p 110 jenda@coralmyn.hrach.eu +fi +