Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91157f970d | ||
|
|
63ffd98ae8 | ||
|
|
e6471070a6 | ||
|
|
c6544f29c8 | ||
|
|
bd3ca2f413 | ||
|
|
87366336c5 | ||
|
|
140b272f6c | ||
|
|
a7461f6e2c | ||
|
|
3c07be0160 | ||
|
|
61cf7696a5 | ||
|
|
3d9ad49020 | ||
|
|
1b6d0c40f3 | ||
|
|
235ef32efd | ||
|
|
85598adaab | ||
|
|
70934a2f85 | ||
|
|
5cec28cad1 | ||
|
|
d57e66c033 | ||
|
|
d9114d21c7 | ||
|
|
2108ef2418 | ||
|
|
a2d1d42362 | ||
|
|
32bcb1b18a | ||
|
|
1e4aadee06 | ||
|
|
a82e75999b | ||
|
|
7b95b69c7b | ||
|
|
e0aef726bd | ||
|
|
7707a9da6d | ||
|
|
ca2e2e2e4a | ||
|
|
208cc54e53 | ||
|
|
f7dd572fe1 | ||
|
|
69dba202d0 | ||
|
|
3d6bd7bac5 | ||
|
|
0f9be13e31 | ||
|
|
be86248cce | ||
|
|
01d060cf5f | ||
|
|
8c5b34073d | ||
|
|
fe81014794 | ||
|
|
f308bca1fb | ||
|
|
c6b518bd15 | ||
|
|
abaea8673a | ||
|
|
4f83ee3a40 | ||
|
|
c453e923ae | ||
|
|
bc9336ef21 | ||
|
|
541029a02d | ||
|
|
04f832ac00 | ||
|
|
5551b6438a | ||
|
|
1a34486a84 | ||
|
|
53eb6a9fba | ||
|
|
243d91abb3 | ||
|
|
f666075f3e | ||
|
|
2045496ef4 | ||
|
|
fd02d162aa | ||
|
|
b729e91aaa | ||
|
|
3ad649a402 | ||
|
|
7c1e13e32e | ||
|
|
37179e65a1 | ||
|
|
73bebf0aec | ||
|
|
8fda78c466 | ||
|
|
38c827d0d8 | ||
|
|
0f3416b49a |
+5
-4
@@ -1,7 +1,8 @@
|
|||||||
.stack-work/
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.setjonpass
|
|
||||||
elm-stuff
|
|
||||||
static/jon.js
|
|
||||||
__pycache__
|
__pycache__
|
||||||
*.swp
|
*.swp
|
||||||
|
jon/config.json
|
||||||
|
elm-stuff/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
jon/static/*.js
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
FROM debian:latest as builder
|
||||||
|
|
||||||
|
RUN apt-get update
|
||||||
|
RUN apt-get upgrade -y
|
||||||
|
RUN apt-get install -y elm-compiler make ca-certificates
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
WORKDIR /app
|
||||||
|
RUN rm -rf .venv venv
|
||||||
|
|
||||||
|
RUN make frontend
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.11-alpine as runner
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
RUN pip install gunicorn
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
ENV JON_DB_CONNECTION_STRING="host=fsmi-db.fsmi.org dbname=garfield"
|
||||||
|
ENV JON_SECRET_KEY="changeme"
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "gunicorn -b '0.0.0.0:5000' --chdir /app 'jon:create_app()'"]
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Copyright 2022 Paul Brinkmeier
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
static/jon.js: elm/*.elm $(wildcard elm/**/*.elm)
|
.PHONY: frontend
|
||||||
elm make --debug --output static/jon.js elm/Main.elm
|
|
||||||
|
frontend: jon/static/entry.js
|
||||||
|
|
||||||
|
jon/static/entry.js: $(shell find frontend -name '*.elm')
|
||||||
|
elm make --optimize frontend/Entry.elm --output $@
|
||||||
|
|||||||
@@ -1,8 +1,85 @@
|
|||||||
# jon
|
# jon
|
||||||
|
|
||||||
> the tamer of garfield
|
> the tamer of garfield
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
`jon` is a Python WSGI application written using Flask.
|
||||||
|
This means you'll have to install a bunch of Python packages to get up and running.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
You should probably use a virtualenv for that.
|
||||||
|
I develop `jon` using Python 3.10 but it should work with older versions as well.
|
||||||
|
|
||||||
|
#### Arch Linux
|
||||||
|
|
||||||
|
If you're on Arch, these packages are required for running the server:
|
||||||
|
|
||||||
|
```
|
||||||
|
python python-flask python-flask-login python-psycopg2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building Frontend JS
|
||||||
|
|
||||||
|
Most of jon works without JS but there are some features that require it.
|
||||||
|
The frontend code lives in `./frontend` and is written using Elm, a functional language that compiles to JS.
|
||||||
|
To compile the Elm code to `.js` files, first make sure that the Elm compiler is installed:
|
||||||
|
|
||||||
|
```
|
||||||
|
elm --version
|
||||||
|
# 0.19.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run `make frontend`.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```
|
||||||
|
flask --app jon run --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
`--debug` restarts the server when a source file changes.
|
||||||
|
|
||||||
|
## Running with docker
|
||||||
|
When you prefer running the application using docker you can just use
|
||||||
|
```
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
In case your local username does not line up with your FSMI-username, you need to specify your FSMI-username
|
||||||
|
using `USER=<username>`, e.g.:
|
||||||
|
```
|
||||||
|
USER=shirkanesi docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
This can also be persisted by following the instructions in the docker-compose.yml
|
||||||
|
|
||||||
## fsmi-db forward
|
## fsmi-db forward
|
||||||
|
|
||||||
```
|
```
|
||||||
ssh -nNTvL 5432:fsmi-db.fsmi.uni-karlsruhe.de:5432 fsmi-login
|
ssh -nNTvL 5432:fsmi-db.fsmi.org:5432 fsmi-login.fsmi.uni-karlsruhe.de
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] Implement and document report generation
|
||||||
|
- [ ] How many days will the item last?
|
||||||
|
- [ ] How many do we need to last X months?
|
||||||
|
- [ ] etc.
|
||||||
|
- [ ] Make it print nicely
|
||||||
|
- [ ] Make it possible to edit entries
|
||||||
|
- [ ] Fix unsafe client-side sessions, either:
|
||||||
|
- [ ] Use `flask-session` for file-backed sessions
|
||||||
|
- [ ] Use `flask-login` with a single user stored in memory
|
||||||
|
- [ ] Improve project structure
|
||||||
|
- [ ] Use `flask.flash` for error messages
|
||||||
|
- [x] Implement item and snack entry as Elm application
|
||||||
|
- [x] Figure out/Add documentation about building `entry.js`
|
||||||
|
- [ ] Clean up the code a little and add some comments
|
||||||
|
- [ ] Needs good documentation for maintainability
|
||||||
|
- [ ] Use cool new function for deactivating items
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
module Main (main) where
|
|
||||||
|
|
||||||
import Jon.Main (main)
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
{ pkgs ? import ./nix/pkgs.nix {} }:
|
|
||||||
let
|
|
||||||
ghc = import ./nix/ghc924.nix pkgs;
|
|
||||||
haskellDeps = import ./nix/haskell-deps.nix;
|
|
||||||
|
|
||||||
tools = with pkgs; [
|
|
||||||
# nix tools
|
|
||||||
niv
|
|
||||||
nix-tree
|
|
||||||
|
|
||||||
# haskell tools
|
|
||||||
stack
|
|
||||||
(ghc.ghcWithPackages haskellDeps)
|
|
||||||
|
|
||||||
# elm tools
|
|
||||||
elmPackages.elm
|
|
||||||
];
|
|
||||||
in
|
|
||||||
pkgs.mkShellNoCC {
|
|
||||||
buildInputs = tools;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
version: '3.7'
|
||||||
|
|
||||||
|
services:
|
||||||
|
jon:
|
||||||
|
container_name: jon
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ~/.pgpass:/root/.pgpass:ro
|
||||||
|
dns:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 8.8.8.8
|
||||||
|
network_mode: bridge
|
||||||
|
environment:
|
||||||
|
# If your local user is different from your fsmi user, change $USER here!
|
||||||
|
- JON_DB_CONNECTION_STRING="host=fsmi-db.fsmi.org dbname=garfield user=$USER"
|
||||||
|
- JON_SECRET_KEY="changemetosomethingsuperrandomandsecure"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"type": "application",
|
"type": "application",
|
||||||
"source-directories": [
|
"source-directories": [
|
||||||
"elm"
|
"frontend"
|
||||||
],
|
],
|
||||||
"elm-version": "0.19.1",
|
"elm-version": "0.19.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
-425
@@ -1,425 +0,0 @@
|
|||||||
module Main exposing (..)
|
|
||||||
|
|
||||||
import Browser
|
|
||||||
import Dict exposing (Dict)
|
|
||||||
import Html exposing (..)
|
|
||||||
import Html.Attributes exposing
|
|
||||||
( checked
|
|
||||||
, disabled
|
|
||||||
, style
|
|
||||||
, type_
|
|
||||||
, value
|
|
||||||
)
|
|
||||||
import Html.Events exposing (..)
|
|
||||||
import Http
|
|
||||||
import Json.Decode as Dec
|
|
||||||
import Json.Decode.Pipeline exposing (..)
|
|
||||||
import Json.Encode as Enc
|
|
||||||
import Set exposing (Set)
|
|
||||||
|
|
||||||
main = Browser.document
|
|
||||||
{ init = \() -> init
|
|
||||||
, subscriptions = \_ -> Sub.none
|
|
||||||
, update = update
|
|
||||||
, view = \outerState -> { title = "jon", body = [ view outerState ] }
|
|
||||||
}
|
|
||||||
|
|
||||||
getOverviewItems : Int -> Cmd Msg
|
|
||||||
getOverviewItems location = Http.post
|
|
||||||
{ url = "/rpc/getOverviewItems"
|
|
||||||
, body = Http.jsonBody <| Enc.object
|
|
||||||
[ ("location", Enc.int location)
|
|
||||||
]
|
|
||||||
, expect = Http.expectJson RcvOverview <| Dec.list decodeOI
|
|
||||||
}
|
|
||||||
|
|
||||||
rpc : { func : String, args : Enc.Value, expect : Dec.Decoder a } -> (Result Http.Error a -> b) -> Cmd b
|
|
||||||
rpc { func, args, expect } mkMsg = Http.post
|
|
||||||
{ url = "/rpc/" ++ func
|
|
||||||
, body = Http.jsonBody args
|
|
||||||
, expect = Http.expectJson mkMsg expect
|
|
||||||
}
|
|
||||||
|
|
||||||
getLocations : Cmd Msg
|
|
||||||
getLocations = rpc
|
|
||||||
{ func = "getLocations"
|
|
||||||
, args = Enc.object []
|
|
||||||
, expect = Dec.list (Dec.succeed Location
|
|
||||||
|> required "id" Dec.int
|
|
||||||
|> required "name" Dec.string)
|
|
||||||
} RcvLocations
|
|
||||||
|
|
||||||
adjustInventory : Int -> Int -> String -> Cmd Msg
|
|
||||||
adjustInventory itemId adjustment description = Http.post
|
|
||||||
{ url = "/rpc/adjustInventory"
|
|
||||||
, body = Http.jsonBody <| Enc.object
|
|
||||||
[ ("item", Enc.int itemId)
|
|
||||||
, ("amount", Enc.int adjustment)
|
|
||||||
, ("description", Enc.string description)
|
|
||||||
]
|
|
||||||
, expect = Http.expectWhatever (\_ -> RcvOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
type alias InventoryTransferDTO = { from : Int, to : Int, amount : Int }
|
|
||||||
|
|
||||||
transferInventory : List InventoryTransferDTO -> Cmd Msg
|
|
||||||
transferInventory transfers = Http.post
|
|
||||||
{ url = "/rpc/transferInventory"
|
|
||||||
, body = Http.jsonBody (Enc.object
|
|
||||||
[ ("transfers", Enc.list encodeTransfer transfers)
|
|
||||||
])
|
|
||||||
, expect = Http.expectWhatever (\_ -> RcvOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
encodeTransfer t = Enc.object
|
|
||||||
[ ("from", Enc.int t.from)
|
|
||||||
, ("to", Enc.int t.to)
|
|
||||||
, ("amount", Enc.int t.amount)
|
|
||||||
]
|
|
||||||
|
|
||||||
disableItems : List Int -> Cmd Msg
|
|
||||||
disableItems ids = Http.post
|
|
||||||
{ url = "/rpc/disableItems"
|
|
||||||
, body = Http.jsonBody (Enc.object
|
|
||||||
[ ("items", Enc.list Enc.int ids)
|
|
||||||
])
|
|
||||||
, expect = Http.expectWhatever (\_ -> RcvOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteSnack : Int -> Cmd Msg
|
|
||||||
deleteSnack snack = Http.post
|
|
||||||
{ url = "/rpc/deleteSnack"
|
|
||||||
, body = Http.jsonBody (Enc.object
|
|
||||||
[ ("snack", Enc.int snack)
|
|
||||||
])
|
|
||||||
, expect = Http.expectWhatever (\_ -> RcvOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
decodeOI = Dec.succeed OverviewItem
|
|
||||||
|> requiredAt ["item", "id"] Dec.int
|
|
||||||
|> requiredAt ["item", "barcode"] Dec.string
|
|
||||||
|> requiredAt ["item", "name"] Dec.string
|
|
||||||
|> requiredAt ["overview", "unitsLeft"] Dec.int
|
|
||||||
|> requiredAt ["item", "unitPrice"] Dec.float
|
|
||||||
|> requiredAt ["item", "bought"] Dec.string
|
|
||||||
|> requiredAt ["overview", "activeMappings"] Dec.int
|
|
||||||
|> requiredAt ["item", "group"] Dec.int
|
|
||||||
|> requiredAt ["overview", "groupName"] Dec.string
|
|
||||||
|
|
||||||
getSnacksByItem : OverviewItem -> Cmd Msg
|
|
||||||
getSnacksByItem item = rpc
|
|
||||||
{ func = "getSnacksByItemId"
|
|
||||||
, args = Enc.object
|
|
||||||
[ ("item", Enc.int item.id)
|
|
||||||
]
|
|
||||||
, expect = Dec.list decodeSnack
|
|
||||||
} (RcvSnacks item)
|
|
||||||
|
|
||||||
type alias Snack =
|
|
||||||
{ id : Int
|
|
||||||
, name : String
|
|
||||||
, barcode : String
|
|
||||||
, price : Float
|
|
||||||
, location : Int
|
|
||||||
, taxGroup : Int
|
|
||||||
}
|
|
||||||
|
|
||||||
decodeSnack = Dec.succeed Snack
|
|
||||||
|> required "id" Dec.int
|
|
||||||
|> required "name" Dec.string
|
|
||||||
|> required "barcode" Dec.string
|
|
||||||
|> required "price" Dec.float
|
|
||||||
|> required "location" Dec.int
|
|
||||||
|> required "taxGroup" Dec.int
|
|
||||||
|
|
||||||
type alias OverviewItem =
|
|
||||||
{ id : Int
|
|
||||||
, barcode : String
|
|
||||||
, name : String
|
|
||||||
, unitsLeft : Int
|
|
||||||
, price : Float
|
|
||||||
, bought : String
|
|
||||||
, activeMappings : Int
|
|
||||||
, groupId : Int
|
|
||||||
, groupName : String
|
|
||||||
}
|
|
||||||
|
|
||||||
type alias Location =
|
|
||||||
{ id : Int
|
|
||||||
, name : String
|
|
||||||
}
|
|
||||||
|
|
||||||
type OuterState
|
|
||||||
= LoadingLocations
|
|
||||||
| LocationSelector (List Location)
|
|
||||||
| Initialized { locations : List Location, location : Location } State
|
|
||||||
|
|
||||||
type State
|
|
||||||
= Overview
|
|
||||||
{ selectedItems : Set Int
|
|
||||||
, desiredInventory : Dict Int Int
|
|
||||||
, overviewItems : List OverviewItem
|
|
||||||
}
|
|
||||||
| ViewingItem
|
|
||||||
{ item : OverviewItem
|
|
||||||
, snacks : List Snack
|
|
||||||
}
|
|
||||||
|
|
||||||
type Msg
|
|
||||||
= SelectLocation Location
|
|
||||||
| SelectItem Int Bool
|
|
||||||
| ChangeLocation
|
|
||||||
| SetDesiredInventory Int String
|
|
||||||
| TransferInventory Int
|
|
||||||
| GoBack
|
|
||||||
-- RPC calls
|
|
||||||
| CallDisableItems (List Int)
|
|
||||||
| CallAdjustInventory Int Int String
|
|
||||||
| CallGetSnacksById OverviewItem
|
|
||||||
| CallDeleteSnack Int
|
|
||||||
-- Responses
|
|
||||||
| RcvLocations (Result Http.Error (List Location))
|
|
||||||
| RcvOverview (Result Http.Error (List OverviewItem))
|
|
||||||
| RcvSnacks OverviewItem (Result Http.Error (List Snack))
|
|
||||||
| RcvOther
|
|
||||||
|
|
||||||
init = (LoadingLocations, getLocations)
|
|
||||||
|
|
||||||
update msg outerState = case msg of
|
|
||||||
CallAdjustInventory item amount desc -> (outerState, adjustInventory item amount desc)
|
|
||||||
CallDisableItems items -> (outerState, disableItems items)
|
|
||||||
CallGetSnacksById item -> (outerState, getSnacksByItem item)
|
|
||||||
CallDeleteSnack snack -> (outerState, deleteSnack snack)
|
|
||||||
_ -> case outerState of
|
|
||||||
LoadingLocations -> case msg of
|
|
||||||
RcvLocations (Ok locations) ->
|
|
||||||
(LocationSelector locations, Cmd.none)
|
|
||||||
_ ->
|
|
||||||
(outerState, Cmd.none)
|
|
||||||
LocationSelector locations -> case msg of
|
|
||||||
SelectLocation location ->
|
|
||||||
(Initialized { locations = locations, location = location } <| Overview
|
|
||||||
{ selectedItems = Set.empty
|
|
||||||
, desiredInventory = Dict.empty
|
|
||||||
, overviewItems = []
|
|
||||||
}
|
|
||||||
, getOverviewItems location.id
|
|
||||||
)
|
|
||||||
_ ->
|
|
||||||
(outerState, Cmd.none)
|
|
||||||
Initialized global state -> case msg of
|
|
||||||
ChangeLocation ->
|
|
||||||
(LocationSelector global.locations, Cmd.none)
|
|
||||||
_ ->
|
|
||||||
let
|
|
||||||
(newState, cmd) = stateMachine msg global state
|
|
||||||
in
|
|
||||||
(Initialized global newState, cmd)
|
|
||||||
|
|
||||||
stateMachine msg global state = case state of
|
|
||||||
Overview model -> case msg of
|
|
||||||
RcvOverview (Ok overviewItems) ->
|
|
||||||
(Overview
|
|
||||||
{ selectedItems = Set.empty
|
|
||||||
, desiredInventory = Dict.empty
|
|
||||||
, overviewItems = overviewItems
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
RcvSnacks item (Ok snacks) ->
|
|
||||||
(ViewingItem { item = item, snacks = snacks }, Cmd.none)
|
|
||||||
RcvOther ->
|
|
||||||
(state, getOverviewItems global.location.id)
|
|
||||||
SelectItem itemId selected ->
|
|
||||||
(Overview { model | selectedItems = setSelect itemId selected model.selectedItems }, Cmd.none)
|
|
||||||
SetDesiredInventory itemId invStr -> case String.toInt invStr of
|
|
||||||
Just inv ->
|
|
||||||
(Overview { model | desiredInventory = Dict.insert itemId inv model.desiredInventory }, Cmd.none)
|
|
||||||
Nothing ->
|
|
||||||
(state, Cmd.none)
|
|
||||||
TransferInventory targetId ->
|
|
||||||
let
|
|
||||||
transfers =
|
|
||||||
model.overviewItems
|
|
||||||
|> List.filterMap (\oi ->
|
|
||||||
if Set.member oi.id model.selectedItems
|
|
||||||
then Just { from = oi.id, to = targetId, amount = oi.unitsLeft }
|
|
||||||
else Nothing)
|
|
||||||
in
|
|
||||||
(state, transferInventory transfers)
|
|
||||||
_ ->
|
|
||||||
(state, Cmd.none)
|
|
||||||
ViewingItem { item } -> case msg of
|
|
||||||
GoBack ->
|
|
||||||
(Overview { selectedItems = Set.empty, desiredInventory = Dict.empty, overviewItems = [] }, getOverviewItems global.location.id)
|
|
||||||
RcvSnacks item_ (Ok snacks) ->
|
|
||||||
(ViewingItem { item = item_, snacks = snacks }, Cmd.none)
|
|
||||||
RcvOther ->
|
|
||||||
(state, getSnacksByItem item)
|
|
||||||
_ ->
|
|
||||||
(state, Cmd.none)
|
|
||||||
|
|
||||||
view outerState = case outerState of
|
|
||||||
LoadingLocations -> progress [] []
|
|
||||||
LocationSelector locations ->
|
|
||||||
let
|
|
||||||
viewLocationButton location =
|
|
||||||
button [ onClick <| SelectLocation location ] [ text location.name ]
|
|
||||||
in
|
|
||||||
div []
|
|
||||||
[ p [] [ text "Raum auswählen:" ]
|
|
||||||
, div [] <| List.map viewLocationButton locations
|
|
||||||
]
|
|
||||||
Initialized global state ->
|
|
||||||
div []
|
|
||||||
[ h2 []
|
|
||||||
[ text <| "Inventar " ++ global.location.name ++ " "
|
|
||||||
, button [ onClick ChangeLocation ] [ text "Raum ändern" ]
|
|
||||||
]
|
|
||||||
, viewState global state
|
|
||||||
]
|
|
||||||
|
|
||||||
viewState global state = case state of
|
|
||||||
Overview { selectedItems, desiredInventory, overviewItems } ->
|
|
||||||
let
|
|
||||||
header = tableCells th <| List.map text [ "", "ID", "Artikel", "EAN", "Preis", "Kaufdatum", "Snackeinträge", "Inventar", "Aktionen" ]
|
|
||||||
viewOverviewItem oi =
|
|
||||||
let
|
|
||||||
adjustedInventory = Maybe.withDefault oi.unitsLeft <| Dict.get oi.id desiredInventory
|
|
||||||
mkAdjustInventoryMsg itemId adjustment =
|
|
||||||
if adjustment > 0
|
|
||||||
then CallAdjustInventory itemId adjustment "Gewinn"
|
|
||||||
else CallAdjustInventory itemId adjustment "Verlust"
|
|
||||||
viewAdjustedInventory adjustment =
|
|
||||||
if adjustment == 0
|
|
||||||
then ""
|
|
||||||
else if adjustment > 0
|
|
||||||
then "(+" ++ String.fromInt adjustment ++ ")"
|
|
||||||
else "(" ++ String.fromInt adjustment ++ ")"
|
|
||||||
|
|
||||||
sumSelected =
|
|
||||||
overviewItems
|
|
||||||
|> List.filter (\x -> Set.member x.id selectedItems)
|
|
||||||
|> List.map .unitsLeft
|
|
||||||
|> List.sum
|
|
||||||
in
|
|
||||||
tableCells td
|
|
||||||
[ input
|
|
||||||
[ type_ "checkbox"
|
|
||||||
, onCheck <| SelectItem oi.id
|
|
||||||
, checked <| Set.member oi.id selectedItems
|
|
||||||
] []
|
|
||||||
, text <| String.fromInt oi.id
|
|
||||||
, text oi.name
|
|
||||||
, code [] [ text oi.barcode ]
|
|
||||||
, text <| showEuros oi.price
|
|
||||||
, text <| Tuple.first <| splitAt 'T' oi.bought
|
|
||||||
, text <| String.fromInt oi.activeMappings
|
|
||||||
, input
|
|
||||||
[ type_ "number"
|
|
||||||
, onInput <| SetDesiredInventory oi.id
|
|
||||||
, value <| String.fromInt adjustedInventory
|
|
||||||
, style "width" "5em"
|
|
||||||
] []
|
|
||||||
, div []
|
|
||||||
[ button
|
|
||||||
(if adjustedInventory == oi.unitsLeft
|
|
||||||
then [ disabled True ]
|
|
||||||
else [ onClick <| mkAdjustInventoryMsg oi.id <| adjustedInventory - oi.unitsLeft ])
|
|
||||||
[ text <| "Inventar korrigieren" ++ viewAdjustedInventory (adjustedInventory - oi.unitsLeft) ]
|
|
||||||
, button
|
|
||||||
(if oi.unitsLeft /= 0
|
|
||||||
then [ disabled True ]
|
|
||||||
else [ onClick <| CallDisableItems [oi.id] ])
|
|
||||||
[ text "Eintrag deaktivieren" ]
|
|
||||||
, button
|
|
||||||
(if Set.member oi.id selectedItems || sumSelected == 0
|
|
||||||
then [ disabled True ]
|
|
||||||
else [ onClick <| TransferInventory oi.id ])
|
|
||||||
[ text <| String.fromInt sumSelected ++ " Einheiten umbuchen" ]
|
|
||||||
, button [ onClick <| CallGetSnacksById oi ] [ text "Anzeigen" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
in
|
|
||||||
div []
|
|
||||||
[ table [] <| [header] ++ List.map viewOverviewItem overviewItems
|
|
||||||
]
|
|
||||||
ViewingItem { item, snacks } ->
|
|
||||||
let
|
|
||||||
header = tableCells th <| List.map text [ "ID", "Artikel", "Barcode", "Bruttoverkaufspreis", "Aktionen" ]
|
|
||||||
viewSnack snack = tableCells td
|
|
||||||
[ text <| String.fromInt snack.id
|
|
||||||
, text snack.name
|
|
||||||
, code [] [ text snack.barcode ]
|
|
||||||
, text <| showEuros snack.price ++ " (+" ++ showEuros (roundTo 2 <| snack.price - item.price) ++ ")"
|
|
||||||
, div []
|
|
||||||
[ button [ onClick <| CallDeleteSnack snack.id ] [ text "Deaktivieren" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
itemProp label value = tr []
|
|
||||||
[ th [ style "text-align" "left" ] [ text label ]
|
|
||||||
, td [] value
|
|
||||||
]
|
|
||||||
in
|
|
||||||
div []
|
|
||||||
[ button [ onClick GoBack ] [ text "Zurück" ]
|
|
||||||
, fieldset []
|
|
||||||
[ legend [] [ text <| "Inventareintrag " ++ String.fromInt item.id ]
|
|
||||||
, table []
|
|
||||||
[ tbody []
|
|
||||||
[ itemProp "ID" [ text <| String.fromInt item.id ]
|
|
||||||
, itemProp "EAN" [ code [] [ text item.barcode ] ]
|
|
||||||
, itemProp "Artikel" [ text item.name ]
|
|
||||||
, itemProp "Gruppe" [ text <| item.groupName ++ " (" ++ String.fromInt item.groupId ++ ")" ]
|
|
||||||
, itemProp "Inventar" [ text <| String.fromInt item.unitsLeft ]
|
|
||||||
, itemProp "Kaufdatum" [ text <| Tuple.first <| splitAt 'T' item.bought ]
|
|
||||||
, itemProp "Nettoeinkaufspreis" [ text <| showEuros item.price ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, h3 [] [ text "Snacks" ]
|
|
||||||
, table []
|
|
||||||
[ thead [] [ header ]
|
|
||||||
, tbody [] <| List.map viewSnack snacks
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
-- utils
|
|
||||||
|
|
||||||
tableCells f =
|
|
||||||
let
|
|
||||||
mkTd elem = f [] [ elem ]
|
|
||||||
in
|
|
||||||
tr [] << List.map mkTd
|
|
||||||
|
|
||||||
setSelect elem state =
|
|
||||||
(if state then Set.insert else Set.remove) elem
|
|
||||||
|
|
||||||
splitAt : Char -> String -> (String, String)
|
|
||||||
splitAt delim str =
|
|
||||||
let
|
|
||||||
locate c s = case String.uncons s of
|
|
||||||
Nothing -> 0
|
|
||||||
Just (x, xs) ->
|
|
||||||
if x == c then
|
|
||||||
0
|
|
||||||
else
|
|
||||||
1 + locate c xs
|
|
||||||
|
|
||||||
firstOcc = locate delim str
|
|
||||||
in
|
|
||||||
(String.slice 0 firstOcc str, String.slice (firstOcc + 1) (String.length str) str)
|
|
||||||
|
|
||||||
showEuros : Float -> String
|
|
||||||
showEuros x =
|
|
||||||
let
|
|
||||||
(whole, fractional) = splitAt '.' (String.fromFloat x)
|
|
||||||
in
|
|
||||||
whole ++ "," ++ String.slice 0 2 (fractional ++ "00") ++ "€"
|
|
||||||
|
|
||||||
roundTo : Int -> Float -> Float
|
|
||||||
roundTo decimals x =
|
|
||||||
let
|
|
||||||
m = toFloat <| 10^decimals
|
|
||||||
in
|
|
||||||
toFloat (round (x * m)) / m
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
module Calculator exposing (..)
|
||||||
|
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Html.Events exposing (..)
|
||||||
|
|
||||||
|
import NumberInput
|
||||||
|
import Select
|
||||||
|
|
||||||
|
type Tax = Net | Gross
|
||||||
|
|
||||||
|
-- Duplicated from Entry.elm but too lazy to sandwich this out
|
||||||
|
type alias TaxGroup =
|
||||||
|
{ id : Int
|
||||||
|
, description : String
|
||||||
|
, percentage : Float
|
||||||
|
}
|
||||||
|
|
||||||
|
showTax : Tax -> String
|
||||||
|
showTax tax = case tax of
|
||||||
|
Gross -> "Brutto"
|
||||||
|
Net -> "Netto"
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ tax : Select.Model Tax
|
||||||
|
, bundlePrice : NumberInput.Model Float
|
||||||
|
, bundleSize : NumberInput.Model Int
|
||||||
|
}
|
||||||
|
|
||||||
|
init : Float -> Model
|
||||||
|
init bundlePrice = Model
|
||||||
|
(Select.init showTax showTax Net [Net, Gross])
|
||||||
|
(NumberInput.fromFloat bundlePrice)
|
||||||
|
(NumberInput.fromInt 1)
|
||||||
|
|
||||||
|
getResult : Model -> TaxGroup -> Maybe Float
|
||||||
|
getResult model taxGroup =
|
||||||
|
case (NumberInput.get model.bundlePrice, NumberInput.get model.bundleSize) of
|
||||||
|
(Just bundlePrice, Just bundleSize) ->
|
||||||
|
Just <| roundTo 2 <|
|
||||||
|
if Select.get model.tax == Gross then
|
||||||
|
(bundlePrice / toFloat bundleSize) / (1 + taxGroup.percentage)
|
||||||
|
else
|
||||||
|
bundlePrice / toFloat bundleSize
|
||||||
|
_ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= SetTax String
|
||||||
|
| SetBundlePrice String
|
||||||
|
| SetBundleSize String
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model = case msg of
|
||||||
|
SetTax key ->
|
||||||
|
{ model | tax = Select.update key model.tax }
|
||||||
|
SetBundlePrice str ->
|
||||||
|
{ model | bundlePrice = NumberInput.update str model.bundlePrice }
|
||||||
|
SetBundleSize str ->
|
||||||
|
{ model | bundleSize = NumberInput.update str model.bundleSize }
|
||||||
|
|
||||||
|
view : Model -> TaxGroup -> Html Msg
|
||||||
|
view model taxGroup =
|
||||||
|
let
|
||||||
|
mainPart =
|
||||||
|
[ text "("
|
||||||
|
, div [ class "form-input --inline" ]
|
||||||
|
[ label [ for "bundle-price" ] [ text "Gebindepreis" ]
|
||||||
|
, input
|
||||||
|
[ placeholder "Gebindepreis"
|
||||||
|
, value <| NumberInput.show model.bundlePrice
|
||||||
|
, onInput SetBundlePrice
|
||||||
|
, id "bundle-price"
|
||||||
|
, type_ "number"
|
||||||
|
, step "0.01"
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, text " ÷ "
|
||||||
|
, div [ class "form-input --inline" ]
|
||||||
|
[ label [ for "bundle-size" ] [ text "Gebindegröße" ]
|
||||||
|
, input
|
||||||
|
[ placeholder "Gebindegröße"
|
||||||
|
, value <| NumberInput.show model.bundleSize
|
||||||
|
, onInput SetBundleSize
|
||||||
|
, id "bundle-size"
|
||||||
|
, type_ "number"
|
||||||
|
, step "1"
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, text ") "
|
||||||
|
]
|
||||||
|
taxPart =
|
||||||
|
[ text " ÷ "
|
||||||
|
, div [ class "form-input --inline" ]
|
||||||
|
[ label [ for "tax" ] [ text "Steuer" ]
|
||||||
|
, input
|
||||||
|
[ value <| String.fromFloat <| 1 + taxGroup.percentage
|
||||||
|
, id "tax"
|
||||||
|
, type_ "number"
|
||||||
|
, step "0.01"
|
||||||
|
, disabled True
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
resultPart =
|
||||||
|
[ text " = "
|
||||||
|
, text <| Maybe.withDefault "?" <| Maybe.map String.fromFloat <| getResult model taxGroup
|
||||||
|
]
|
||||||
|
in
|
||||||
|
fieldset []
|
||||||
|
[ legend [] [ text "Preisrechner" ]
|
||||||
|
, div [] <| List.concat <| List.filterMap identity
|
||||||
|
[ Just mainPart
|
||||||
|
, if Select.get model.tax == Gross then Just taxPart else Nothing
|
||||||
|
, Just resultPart
|
||||||
|
]
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "calculator-tax" ] [ text "Gebindepreis ist" ]
|
||||||
|
, Select.view [] SetTax model.tax
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
roundTo : Int -> Float -> Float
|
||||||
|
roundTo places x = toFloat (round <| x * 10 ^ toFloat places) / 10 ^ toFloat places
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
module Entry exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Http
|
||||||
|
|
||||||
|
import Json.Decode as D
|
||||||
|
import Json.Decode.Pipeline as P
|
||||||
|
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Html.Events exposing (..)
|
||||||
|
|
||||||
|
import Calculator
|
||||||
|
import NumberInput
|
||||||
|
import Select
|
||||||
|
|
||||||
|
{-
|
||||||
|
Elm forces us to use the Elm architecture:
|
||||||
|
|
||||||
|
┌──────┐
|
||||||
|
┌─────Model──► view ├──Html───────┐
|
||||||
|
│ └──────┘ │
|
||||||
|
│ │
|
||||||
|
┌┴─────────────────────────────────▼┐
|
||||||
|
│ Elm runtime │
|
||||||
|
└▲─────────────────────────────────┬┘
|
||||||
|
│ │
|
||||||
|
│ ┌──────┐ │
|
||||||
|
└─Model+Cmd──┤update◄──Msg+Model──┘
|
||||||
|
└──────┘
|
||||||
|
|
||||||
|
This architecture is similar to what React does but its implementation
|
||||||
|
in Elm is a bit special since it's purely functional and side effects
|
||||||
|
are isolated into the runtime system.
|
||||||
|
|
||||||
|
An Elm component is usually centered around two types, Model and Msg.
|
||||||
|
Model contains all data the application is concerned with, including the state
|
||||||
|
of UI elements. Msg encodes all updates to Model that the application supports.
|
||||||
|
|
||||||
|
In addition to Msg and Model, we have to provide two functions, view and update.
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
update : Msg -> Model -> (Model, Cmd Msg)
|
||||||
|
|
||||||
|
view maps a Model to a DOM tree. Events in this DOM tree create Msg values.
|
||||||
|
update maps a Msg and a Model to a new Model. In addition, update can create
|
||||||
|
a command. Commands are used to make the runtime do side effects, which in
|
||||||
|
turn create new Msg values.
|
||||||
|
|
||||||
|
For example, we have a SetSearchTerm message which simply updates the searchTerm
|
||||||
|
property in the model. This message is triggered every time the search box input
|
||||||
|
is changed. Submitting the search box form triggers a SubmitSearch event.
|
||||||
|
This event leaves the model unchanged but issues a command that sends the search
|
||||||
|
term to a JSON endpoint. When the request successfully resolves, the runtime
|
||||||
|
triggers a ReceiveSearchResults messages which updates the list of search results
|
||||||
|
in the model.
|
||||||
|
|
||||||
|
See Calculator.elm for a simpler example of this architecture.
|
||||||
|
-}
|
||||||
|
|
||||||
|
main = Browser.element
|
||||||
|
{ init = \globals ->
|
||||||
|
( Context globals <| ItemSearch { searchTerm = "", searchResults = [] }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
, subscriptions = \_ -> Sub.none
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Data types
|
||||||
|
|
||||||
|
type alias SearchResult =
|
||||||
|
{ barcode : String
|
||||||
|
, name : String
|
||||||
|
, netUnitPrice : Float
|
||||||
|
, bought : String
|
||||||
|
, salesUnits : Int
|
||||||
|
, available : Bool
|
||||||
|
, locationName : String
|
||||||
|
, locationId : Int
|
||||||
|
, groupName : String
|
||||||
|
, groupId : Int
|
||||||
|
, taxGroupId : Int
|
||||||
|
, unitsLeft : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResultDecoder =
|
||||||
|
D.succeed SearchResult
|
||||||
|
|> P.required "item_barcode" D.string
|
||||||
|
|> P.required "name" D.string
|
||||||
|
|> P.required "unit_price" D.float
|
||||||
|
|> P.required "bought" D.string
|
||||||
|
|> P.required "sales_units" D.int
|
||||||
|
|> P.required "available" D.bool
|
||||||
|
|> P.required "location_name" D.string
|
||||||
|
|> P.required "location_id" D.int
|
||||||
|
|> P.required "group_name" D.string
|
||||||
|
|> P.required "group_id" D.int
|
||||||
|
|> P.required "tax_group_id" D.int
|
||||||
|
|> P.required "units_left" D.int
|
||||||
|
|
||||||
|
type alias Location =
|
||||||
|
{ id : Int
|
||||||
|
, name : String
|
||||||
|
}
|
||||||
|
|
||||||
|
type alias Group =
|
||||||
|
{ id : Int
|
||||||
|
, name : String
|
||||||
|
}
|
||||||
|
|
||||||
|
type alias TaxGroup =
|
||||||
|
{ id : Int
|
||||||
|
, description : String
|
||||||
|
, percentage : Float
|
||||||
|
}
|
||||||
|
|
||||||
|
type alias NewItem =
|
||||||
|
{ barcode : String
|
||||||
|
, name : String
|
||||||
|
, salesUnits : Int
|
||||||
|
, group : Group
|
||||||
|
, location : Location
|
||||||
|
, netUnitPrice : Float
|
||||||
|
, taxGroup : TaxGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
type alias Context =
|
||||||
|
{ globals : Globals
|
||||||
|
, state : State
|
||||||
|
}
|
||||||
|
|
||||||
|
type alias Globals =
|
||||||
|
{ locations : List Location
|
||||||
|
, defaultLocation : Location
|
||||||
|
, groups : List Group
|
||||||
|
, defaultGroup : Group
|
||||||
|
, taxGroups : List TaxGroup
|
||||||
|
, defaultTaxGroup : TaxGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
type State
|
||||||
|
= ItemSearch
|
||||||
|
{ searchTerm : String
|
||||||
|
, searchResults : List SearchResult
|
||||||
|
}
|
||||||
|
| ItemEditor
|
||||||
|
{ barcode : String
|
||||||
|
, name : String
|
||||||
|
, salesUnits : NumberInput.Model Int
|
||||||
|
, calculator : Calculator.Model
|
||||||
|
, netUnitPrice : NumberInput.Model Float
|
||||||
|
, grossUnitPrice : NumberInput.Model Float
|
||||||
|
, group : Select.Model Group
|
||||||
|
, location : Select.Model Location
|
||||||
|
, taxGroup : Select.Model TaxGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= SetSearchTerm String
|
||||||
|
| SubmitSearch
|
||||||
|
| ReceiveSearchResults (Result Http.Error (List SearchResult))
|
||||||
|
| GotoItemEditor IEInit
|
||||||
|
| SetBarcode String
|
||||||
|
| SetName String
|
||||||
|
| SetSalesUnits String
|
||||||
|
| CalculatorMsg Calculator.Msg
|
||||||
|
| SetNetUnitPrice String
|
||||||
|
| SetGrossUnitPrice String
|
||||||
|
| SetGroup String
|
||||||
|
| SetLocation String
|
||||||
|
| SetTaxGroup String
|
||||||
|
|
||||||
|
type IEInit
|
||||||
|
= IEInitBarcode String
|
||||||
|
| IEInitSearchResult SearchResult
|
||||||
|
|
||||||
|
-- Update logic: State machine etc.
|
||||||
|
|
||||||
|
update msg { globals, state } =
|
||||||
|
let
|
||||||
|
(state_, cmd) = updateState msg globals state
|
||||||
|
in
|
||||||
|
({ globals = globals, state = state_ }, cmd)
|
||||||
|
|
||||||
|
updateState msg globals state = case state of
|
||||||
|
ItemSearch model -> case msg of
|
||||||
|
SetSearchTerm searchTerm ->
|
||||||
|
(ItemSearch { model | searchTerm = searchTerm }, Cmd.none)
|
||||||
|
SubmitSearch ->
|
||||||
|
( state
|
||||||
|
, Http.get
|
||||||
|
{ url = "/entry/api/search-items?search-term=" ++ model.searchTerm
|
||||||
|
, expect = Http.expectJson ReceiveSearchResults <| D.list searchResultDecoder
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ReceiveSearchResults (Ok searchResults) ->
|
||||||
|
(ItemSearch { model | searchResults = searchResults }, Cmd.none)
|
||||||
|
GotoItemEditor (IEInitBarcode barcode) ->
|
||||||
|
( ItemEditor
|
||||||
|
{ barcode = barcode
|
||||||
|
, name = ""
|
||||||
|
, calculator = Calculator.init 0
|
||||||
|
, netUnitPrice = NumberInput.fromFloat 0
|
||||||
|
, grossUnitPrice = NumberInput.fromFloat 0
|
||||||
|
, salesUnits = NumberInput.fromInt 0
|
||||||
|
, group = Select.init (.id >> String.fromInt) (.name) globals.defaultGroup globals.groups
|
||||||
|
, location = Select.init (.id >> String.fromInt) (.name) globals.defaultLocation globals.locations
|
||||||
|
, taxGroup = Select.init (.id >> String.fromInt) (.description) globals.defaultTaxGroup globals.taxGroups
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
GotoItemEditor (IEInitSearchResult searchResult) ->
|
||||||
|
case find (\tg -> tg.id == searchResult.taxGroupId) globals.taxGroups of
|
||||||
|
Nothing -> (state, Cmd.none)
|
||||||
|
Just taxGroup ->
|
||||||
|
( ItemEditor
|
||||||
|
{ barcode = searchResult.barcode
|
||||||
|
, name = searchResult.name
|
||||||
|
, calculator = Calculator.init searchResult.netUnitPrice
|
||||||
|
, netUnitPrice = NumberInput.fromFloat searchResult.netUnitPrice
|
||||||
|
, grossUnitPrice = NumberInput.fromFloat
|
||||||
|
(suggestedGrossPrice searchResult.netUnitPrice taxGroup.percentage)
|
||||||
|
, salesUnits = NumberInput.fromInt searchResult.salesUnits
|
||||||
|
, group = Select.init (.id >> String.fromInt) (.name) { id = searchResult.groupId, name = searchResult.groupName } globals.groups
|
||||||
|
, location = Select.init (.id >> String.fromInt) (.name) { id = searchResult.locationId, name = searchResult.locationName } globals.locations
|
||||||
|
, taxGroup = Select.init (.id >> String.fromInt) (.description) taxGroup globals.taxGroups
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
_ ->
|
||||||
|
(state, Cmd.none)
|
||||||
|
ItemEditor model -> case msg of
|
||||||
|
SetBarcode barcode ->
|
||||||
|
(ItemEditor { model | barcode = barcode }, Cmd.none)
|
||||||
|
SetName name ->
|
||||||
|
(ItemEditor { model | name = name }, Cmd.none)
|
||||||
|
SetSalesUnits str ->
|
||||||
|
(ItemEditor { model | salesUnits = NumberInput.update str model.salesUnits }, Cmd.none)
|
||||||
|
CalculatorMsg msg_ ->
|
||||||
|
(ItemEditor { model | calculator = Calculator.update msg_ model.calculator }, Cmd.none)
|
||||||
|
SetNetUnitPrice str ->
|
||||||
|
( ItemEditor { model | netUnitPrice = NumberInput.update str model.netUnitPrice }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
SetGrossUnitPrice str ->
|
||||||
|
( ItemEditor { model | grossUnitPrice = NumberInput.update str model.grossUnitPrice }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
SetGroup key ->
|
||||||
|
(ItemEditor { model | group = Select.update key model.group }, Cmd.none)
|
||||||
|
SetLocation key ->
|
||||||
|
(ItemEditor { model | location = Select.update key model.location }, Cmd.none)
|
||||||
|
SetTaxGroup key ->
|
||||||
|
( ItemEditor { model | taxGroup = Select.update key model.taxGroup }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
_ ->
|
||||||
|
(state, Cmd.none)
|
||||||
|
|
||||||
|
suggestedGrossPrice netPrice percentage =
|
||||||
|
roundTo 2 <| netPrice * (1 + percentage) + 0.01
|
||||||
|
|
||||||
|
-- View stuff
|
||||||
|
|
||||||
|
view { globals, state } = case state of
|
||||||
|
ItemSearch model ->
|
||||||
|
div []
|
||||||
|
[ div []
|
||||||
|
[ if model.searchTerm == ""
|
||||||
|
then button [ disabled True ] [ text "Neuer Artikel" ]
|
||||||
|
else button [ onClick <| GotoItemEditor <| IEInitBarcode model.searchTerm ] [ text <| "Neuer Artikel mit Barcode " ++ model.searchTerm ]
|
||||||
|
]
|
||||||
|
, fieldset []
|
||||||
|
[ legend [] [ text "Vorlage für Auftrag wählen" ]
|
||||||
|
, Html.form [ onSubmit SubmitSearch ]
|
||||||
|
[ div [ class "form-input" ]
|
||||||
|
[ label [ for "search-term", title "Barcode oder Name" ] [ text "Suchbegriff" ]
|
||||||
|
, input [ onInput SetSearchTerm, value model.searchTerm, id "search-term" ] []
|
||||||
|
]
|
||||||
|
, table [] <| searchResultHeaders :: List.map viewSearchResult model.searchResults
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
ItemEditor model ->
|
||||||
|
Html.form [ method "POST" ]
|
||||||
|
[ fieldset []
|
||||||
|
[ legend [] [ text "Neuer Inventareintrag" ]
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "barcode" ] [ text "Barcode" ]
|
||||||
|
, input [ onInput SetBarcode, value model.barcode, name "barcode", id "barcode" ] []
|
||||||
|
]
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "name" ] [ text "Name" ]
|
||||||
|
, input [ onInput SetName, value model.name, name "name", id "name" ] []
|
||||||
|
]
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "sales-units" ] [ text "Eingekauft" ]
|
||||||
|
, input [ onInput SetSalesUnits, value <| NumberInput.show model.salesUnits, name "sales-units", id "sales-units", type_ "number" ] []
|
||||||
|
]
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "group" ] [ text "Gruppe" ]
|
||||||
|
, Select.view [ name "group-id", id "group" ] SetGroup model.group
|
||||||
|
, input
|
||||||
|
[ type_ "hidden"
|
||||||
|
, name "group-name"
|
||||||
|
, value <| (Select.get model.group).name
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "location" ] [ text "Raum" ]
|
||||||
|
, Select.view [ name "location-id", id "location" ] SetLocation model.location
|
||||||
|
, input
|
||||||
|
[ type_ "hidden"
|
||||||
|
, name "location-name"
|
||||||
|
, value <| (Select.get model.location).name
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "tax-group" ] [ text "Steuergruppe" ]
|
||||||
|
, Select.view [ name "tax-group-id", id "tax-group" ] SetTaxGroup model.taxGroup
|
||||||
|
, input
|
||||||
|
[ type_ "hidden"
|
||||||
|
, name "tax-group-description"
|
||||||
|
, value <| (Select.get model.taxGroup).description
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, Html.map CalculatorMsg <| Calculator.view model.calculator (Select.get model.taxGroup)
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "net-unit-price" ] [ text "Einkaufspreis (Netto)" ]
|
||||||
|
, input
|
||||||
|
[ value <| NumberInput.show model.netUnitPrice
|
||||||
|
, onInput SetNetUnitPrice
|
||||||
|
, type_ "number"
|
||||||
|
, name "net-unit-price"
|
||||||
|
, id "net-unit-price"
|
||||||
|
, step "0.01"
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, viewSetCalculatedPriceButton model
|
||||||
|
]
|
||||||
|
, div [ class "form-input" ]
|
||||||
|
[ label [ for "gross-unit-price" ] [ text "Verkaufspreis (Brutto)" ]
|
||||||
|
, input
|
||||||
|
[ value <| NumberInput.show model.grossUnitPrice
|
||||||
|
, onInput SetGrossUnitPrice
|
||||||
|
, type_ "number"
|
||||||
|
, name "gross-unit-price"
|
||||||
|
, id "gross-unit-price"
|
||||||
|
, step "0.01"
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, viewSetSuggestedPriceButton model
|
||||||
|
]
|
||||||
|
, button [] [ text "Auftrag anlegen" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
viewSetCalculatedPriceButton model =
|
||||||
|
case Calculator.getResult model.calculator (Select.get model.taxGroup) of
|
||||||
|
Nothing ->
|
||||||
|
button [ disabled True ] [ text "Auf ? setzen" ]
|
||||||
|
Just calculatedPrice ->
|
||||||
|
button
|
||||||
|
[ onClick <| SetNetUnitPrice <| String.fromFloat calculatedPrice
|
||||||
|
-- Prevent submitting the form
|
||||||
|
, type_ "button"
|
||||||
|
]
|
||||||
|
[ text <| "Auf " ++ String.fromFloat calculatedPrice ++ " setzen"
|
||||||
|
]
|
||||||
|
|
||||||
|
viewSetSuggestedPriceButton model =
|
||||||
|
case NumberInput.get model.netUnitPrice of
|
||||||
|
Nothing ->
|
||||||
|
button [ disabled True ] [ text "Auf ? setzen" ]
|
||||||
|
Just netUnitPrice ->
|
||||||
|
let
|
||||||
|
grossUnitPrice = suggestedGrossPrice netUnitPrice (Select.get model.taxGroup).percentage
|
||||||
|
in
|
||||||
|
button
|
||||||
|
[ onClick <| SetGrossUnitPrice <| String.fromFloat grossUnitPrice
|
||||||
|
-- Prevent submitting the form
|
||||||
|
, type_ "button"
|
||||||
|
]
|
||||||
|
[ text <| "Auf " ++ String.fromFloat grossUnitPrice ++ " setzen"
|
||||||
|
]
|
||||||
|
|
||||||
|
searchResultHeaders =
|
||||||
|
tr []
|
||||||
|
[ th [] [ text "Barcode" ]
|
||||||
|
, th [] [ text "Name" ]
|
||||||
|
, th [] [ text "Gruppe" ]
|
||||||
|
, th [] [ text "Stückpreis (Netto)" ]
|
||||||
|
, th [] [ text "Eingekauft" ]
|
||||||
|
, th [] [ text "Kaufdatum" ]
|
||||||
|
, th [] [ text "Raum" ]
|
||||||
|
, th [] [ text "Aktiv?" ]
|
||||||
|
, th [] []
|
||||||
|
]
|
||||||
|
|
||||||
|
viewSearchResult model =
|
||||||
|
tr []
|
||||||
|
[ td [] [ code [] [ text model.barcode ] ]
|
||||||
|
, td [] [ text model.name ]
|
||||||
|
, td [] [ text model.groupName ]
|
||||||
|
, td [] [ text <| String.fromFloat model.netUnitPrice ]
|
||||||
|
, td [] [ text <| String.fromInt model.salesUnits ]
|
||||||
|
, td [] [ text model.bought ]
|
||||||
|
, td [] [ text model.locationName ]
|
||||||
|
, td [] [ text <| showBool model.available ]
|
||||||
|
, td []
|
||||||
|
[ Html.form [ onSubmit <| GotoItemEditor <| IEInitSearchResult model ]
|
||||||
|
[ button [] [ text "Als Vorlage verwenden" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
calculateGarfieldPrice model =
|
||||||
|
NumberInput.get model.netUnitPrice |> Maybe.map (\netUnitPrice ->
|
||||||
|
roundTo 2 <| netUnitPrice * (1 + (Select.get model.taxGroup).percentage) + 0.01
|
||||||
|
)
|
||||||
|
|
||||||
|
roundTo places x = toFloat (round <| x * 10 ^ places) / 10 ^ places
|
||||||
|
|
||||||
|
showBool b = case b of
|
||||||
|
True -> "✅"
|
||||||
|
False -> "❌"
|
||||||
|
|
||||||
|
find pred xs = List.head <| List.filter pred xs
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module NumberInput exposing (..)
|
||||||
|
|
||||||
|
type alias Model a =
|
||||||
|
{ value : Maybe a
|
||||||
|
, original : String
|
||||||
|
, convert : String -> Maybe a
|
||||||
|
}
|
||||||
|
|
||||||
|
fromFloat : Float -> Model Float
|
||||||
|
fromFloat x = Model (Just x) (String.fromFloat x) String.toFloat
|
||||||
|
|
||||||
|
fromInt : Int -> Model Int
|
||||||
|
fromInt x = Model (Just x) (String.fromInt x) String.toInt
|
||||||
|
|
||||||
|
get : Model a -> Maybe a
|
||||||
|
get = .value
|
||||||
|
|
||||||
|
withDefault : a -> Model a -> a
|
||||||
|
withDefault d = Maybe.withDefault d << get
|
||||||
|
|
||||||
|
isValid : Model a -> Bool
|
||||||
|
isValid model = model.value /= Nothing
|
||||||
|
|
||||||
|
update : String -> Model a -> Model a
|
||||||
|
update str model =
|
||||||
|
{ model | value = model.convert str, original = str }
|
||||||
|
|
||||||
|
show : Model a -> String
|
||||||
|
show = .original
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
module Select exposing (..)
|
||||||
|
|
||||||
|
import Html exposing (Attribute, Html, option, select, text)
|
||||||
|
import Html.Attributes exposing (selected, value)
|
||||||
|
import Html.Events exposing (onInput)
|
||||||
|
|
||||||
|
type alias Model a =
|
||||||
|
{ identify : a -> String
|
||||||
|
, show : a -> String
|
||||||
|
, selected : a
|
||||||
|
, options : List a
|
||||||
|
}
|
||||||
|
|
||||||
|
init : (a -> String) -> (a -> String) -> a -> List a -> Model a
|
||||||
|
init = Model
|
||||||
|
|
||||||
|
update : String -> Model a -> Model a
|
||||||
|
update key model =
|
||||||
|
case find (\x -> key == model.identify x) model.options of
|
||||||
|
Nothing -> model
|
||||||
|
Just x -> { model | selected = x }
|
||||||
|
|
||||||
|
view : List (Attribute m) -> (String -> m) -> Model a -> Html m
|
||||||
|
view attributes msg model =
|
||||||
|
let
|
||||||
|
viewOption x =
|
||||||
|
option
|
||||||
|
[ selected <| model.identify model.selected == model.identify x, value <| model.identify x ]
|
||||||
|
[ text <| model.show x ]
|
||||||
|
in
|
||||||
|
select ([ onInput msg ] ++ attributes) <| List.map viewOption model.options
|
||||||
|
|
||||||
|
get : Model a -> a
|
||||||
|
get = .selected
|
||||||
|
|
||||||
|
find pred xs = List.head <| List.filter pred xs
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
cabal-version: 1.12
|
|
||||||
|
|
||||||
-- This file has been generated from package.yaml by hpack version 0.35.0.
|
|
||||||
--
|
|
||||||
-- see: https://github.com/sol/hpack
|
|
||||||
|
|
||||||
name: jon
|
|
||||||
version: 0.1.0.0
|
|
||||||
author: Paul Brinkmeier
|
|
||||||
maintainer: paul.brinkmeier@fsmi.uni-karlsruhe.de
|
|
||||||
copyright: 2022 Paul Brinkmeier
|
|
||||||
license: MIT
|
|
||||||
license-file: LICENSE
|
|
||||||
build-type: Simple
|
|
||||||
extra-source-files:
|
|
||||||
README.md
|
|
||||||
|
|
||||||
source-repository head
|
|
||||||
type: git
|
|
||||||
location: https://git.fsmi.org/paul/jon
|
|
||||||
|
|
||||||
library
|
|
||||||
exposed-modules:
|
|
||||||
Jon.Garfield.Queries
|
|
||||||
Jon.Garfield.Types
|
|
||||||
Jon.Main
|
|
||||||
Jon.Server
|
|
||||||
other-modules:
|
|
||||||
Paths_jon
|
|
||||||
hs-source-dirs:
|
|
||||||
src
|
|
||||||
ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
|
|
||||||
build-depends:
|
|
||||||
aeson
|
|
||||||
, base >=4.7 && <5
|
|
||||||
, beam-core
|
|
||||||
, beam-postgres
|
|
||||||
, bytestring
|
|
||||||
, lens
|
|
||||||
, postgresql-simple
|
|
||||||
, scientific
|
|
||||||
, servant
|
|
||||||
, servant-server
|
|
||||||
, servant-swagger
|
|
||||||
, servant-swagger-ui
|
|
||||||
, swagger2
|
|
||||||
, text
|
|
||||||
, time
|
|
||||||
, warp
|
|
||||||
default-language: Haskell2010
|
|
||||||
|
|
||||||
executable jon-exe
|
|
||||||
main-is: Main.hs
|
|
||||||
hs-source-dirs:
|
|
||||||
app
|
|
||||||
ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
|
|
||||||
build-depends:
|
|
||||||
aeson
|
|
||||||
, base >=4.7 && <5
|
|
||||||
, beam-core
|
|
||||||
, beam-postgres
|
|
||||||
, bytestring
|
|
||||||
, jon
|
|
||||||
, lens
|
|
||||||
, postgresql-simple
|
|
||||||
, scientific
|
|
||||||
, servant
|
|
||||||
, servant-server
|
|
||||||
, servant-swagger
|
|
||||||
, servant-swagger-ui
|
|
||||||
, swagger2
|
|
||||||
, text
|
|
||||||
, time
|
|
||||||
, warp
|
|
||||||
default-language: Haskell2010
|
|
||||||
|
|
||||||
test-suite jon-test
|
|
||||||
type: exitcode-stdio-1.0
|
|
||||||
main-is: Spec.hs
|
|
||||||
other-modules:
|
|
||||||
Paths_jon
|
|
||||||
hs-source-dirs:
|
|
||||||
test
|
|
||||||
ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
|
|
||||||
build-depends:
|
|
||||||
aeson
|
|
||||||
, base >=4.7 && <5
|
|
||||||
, beam-core
|
|
||||||
, beam-postgres
|
|
||||||
, bytestring
|
|
||||||
, jon
|
|
||||||
, lens
|
|
||||||
, postgresql-simple
|
|
||||||
, scientific
|
|
||||||
, servant
|
|
||||||
, servant-server
|
|
||||||
, servant-swagger
|
|
||||||
, servant-swagger-ui
|
|
||||||
, swagger2
|
|
||||||
, text
|
|
||||||
, time
|
|
||||||
, warp
|
|
||||||
default-language: Haskell2010
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from flask import Flask, render_template
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
auth,
|
||||||
|
db,
|
||||||
|
entry,
|
||||||
|
inventory,
|
||||||
|
location,
|
||||||
|
template_utils
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_file("default-config.json", load=json.load)
|
||||||
|
# You don't need a config.json. If you don't provide one, default-config.json
|
||||||
|
# is used.
|
||||||
|
app.config.from_file("config.json", load=json.load, silent=True)
|
||||||
|
app.config.from_prefixed_env(prefix="JON")
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
auth.init_app(app)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def utility_processor():
|
||||||
|
return dict(inspect.getmembers(template_utils, inspect.isfunction))
|
||||||
|
|
||||||
|
app.register_blueprint(location.bp)
|
||||||
|
app.register_blueprint(inventory.bp)
|
||||||
|
app.register_blueprint(entry.bp)
|
||||||
|
app.register_blueprint(auth.bp)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
print(f"Jon started. Token: {auth.ACCESS_TOKEN}", file=sys.stderr)
|
||||||
|
# TODO: Get host and port from config
|
||||||
|
print(f"http://localhost:5000/?token={auth.ACCESS_TOKEN}", file=sys.stderr)
|
||||||
|
|
||||||
|
return app
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
from flask import Blueprint, request, redirect, render_template
|
||||||
|
from flask_login import current_user, login_user, logout_user, LoginManager
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
ACCESS_TOKEN = "".join(secrets.choice(string.ascii_lowercase) for i in range(64))
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_PATHS = [
|
||||||
|
"/favicon.ico",
|
||||||
|
"/static/jon.css"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# A poor man's replacement for memory-backed session solution.
|
||||||
|
# We keep exactly one User (and the corresponding UserData) in
|
||||||
|
# memory and use that to store session data.
|
||||||
|
class UserData:
|
||||||
|
location: Optional[Dict[str, Any]]
|
||||||
|
orders: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.location = None
|
||||||
|
self.orders = []
|
||||||
|
|
||||||
|
|
||||||
|
class User:
|
||||||
|
is_authenticated: bool
|
||||||
|
is_active: bool
|
||||||
|
is_anonymous: bool
|
||||||
|
|
||||||
|
data: UserData
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.is_authenticated = True
|
||||||
|
self.is_active = True
|
||||||
|
self.is_anonymous = False
|
||||||
|
|
||||||
|
self.data = UserData()
|
||||||
|
|
||||||
|
def get_id(self) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
|
login_manager = LoginManager(app)
|
||||||
|
the_one_and_only_user = User()
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id: str) -> User:
|
||||||
|
assert user_id == ""
|
||||||
|
return the_one_and_only_user
|
||||||
|
|
||||||
|
# This function denies every request until `auth.ACCESS_TOKEN`
|
||||||
|
# is passed using `?token=` to authenticate the user.
|
||||||
|
# We use this instead of @login_required because otherwise we'd have
|
||||||
|
# to add that annotation to all routes.
|
||||||
|
# See also: https://flask-login.readthedocs.io/en/latest/#flask_login.login_required
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
if "token" in request.args:
|
||||||
|
if request.args["token"] == ACCESS_TOKEN:
|
||||||
|
login_user(the_one_and_only_user)
|
||||||
|
# Reload the page without query parameters
|
||||||
|
return redirect(request.path)
|
||||||
|
|
||||||
|
# Never deny any paths in `ALLOWED_PATHS`
|
||||||
|
if request.path in ALLOWED_PATHS:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return render_template("auth/denied.html"), 403
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/logout")
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
return redirect("/")
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import psycopg2
|
import psycopg2
|
||||||
|
|
||||||
from flask import g
|
from flask import current_app, g
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
if "db" not in g:
|
if "db" not in g:
|
||||||
# TODO: Make this configurable and use a default that works
|
g.db = psycopg2.connect(current_app.config["DB_CONNECTION_STRING"])
|
||||||
# on the pool computers.
|
|
||||||
g.db = psycopg2.connect("host=localhost dbname=garfield")
|
|
||||||
run_query_on(g.db, "add_views.sql", None)
|
run_query_on(g.db, "add_views.sql", None)
|
||||||
|
|
||||||
return g.db
|
return g.db
|
||||||
@@ -11,6 +11,7 @@ SELECT
|
|||||||
inventory_items.available,
|
inventory_items.available,
|
||||||
inventory_items.item_group,
|
inventory_items.item_group,
|
||||||
inventory_items.location,
|
inventory_items.location,
|
||||||
|
inventory_items.tax_group,
|
||||||
inventory_item_groups.group_name,
|
inventory_item_groups.group_name,
|
||||||
COALESCE(b.sales::numeric, 0::numeric) - COALESCE(cancel.count::numeric, 0::numeric) AS sales,
|
COALESCE(b.sales::numeric, 0::numeric) - COALESCE(cancel.count::numeric, 0::numeric) AS sales,
|
||||||
inventory_items.sales_units::numeric - COALESCE(b.sales, 0::bigint)::numeric + COALESCE(c.delta, 0::numeric) + COALESCE(cancel.count::numeric, 0::numeric) AS units_left,
|
inventory_items.sales_units::numeric - COALESCE(b.sales, 0::bigint)::numeric + COALESCE(c.delta, 0::numeric) + COALESCE(cancel.count::numeric, 0::numeric) AS units_left,
|
||||||
@@ -57,3 +58,56 @@ FROM garfield.inventory_items
|
|||||||
) m USING (item_id)
|
) m USING (item_id)
|
||||||
ORDER BY inventory_items.name;
|
ORDER BY inventory_items.name;
|
||||||
|
|
||||||
|
-- How many *other* active inventory lines exist with the same barcode in the same location that are newer?
|
||||||
|
CREATE TEMPORARY VIEW more_recent_inventory_lines_with_same_barcode AS
|
||||||
|
SELECT
|
||||||
|
a.item_id,
|
||||||
|
-- It's important not to count(*) here because item_id is NULL
|
||||||
|
-- when no other inventory lines exist.
|
||||||
|
count(b.item_id) AS other_lines_count
|
||||||
|
FROM garfield.inventory_items AS a
|
||||||
|
LEFT JOIN garfield.inventory_items AS b
|
||||||
|
ON a.item_barcode = b.item_barcode
|
||||||
|
AND a.item_id != b.item_id
|
||||||
|
AND a.location = b.location
|
||||||
|
AND b.available
|
||||||
|
AND b.bought > a.bought
|
||||||
|
GROUP BY a.item_id;
|
||||||
|
|
||||||
|
-- We need to create this table so that we can put an index on it
|
||||||
|
-- Otherwise the join in the consumption graph query becomes much slower
|
||||||
|
-- Perhaps it would be nicer to use a materialized view instead
|
||||||
|
DROP TABLE IF EXISTS last_n_days;
|
||||||
|
CREATE TEMPORARY TABLE last_n_days AS (
|
||||||
|
SELECT
|
||||||
|
generate_series(now() - interval '14 days', now(), interval '1 day')::date AS sale_date
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX last_n_days_sale_date ON last_n_days (sale_date);
|
||||||
|
|
||||||
|
-- Get an array of how often items were sold over the last 14 days
|
||||||
|
CREATE TEMPORARY VIEW inventory_last_n_days_sales AS
|
||||||
|
WITH
|
||||||
|
sales_by_date AS (
|
||||||
|
SELECT
|
||||||
|
inventory_line AS item_id,
|
||||||
|
date_trunc('day', snack_sales_log_timestamp AT TIME ZONE 'UTC+1') AS sale_date,
|
||||||
|
count(*)::int AS sales
|
||||||
|
FROM garfield.snack_sales_log
|
||||||
|
GROUP BY item_id, sale_date
|
||||||
|
),
|
||||||
|
beeg AS (
|
||||||
|
SELECT item_id, sale_date, count(snack_sales_log_timestamp)::int AS sales
|
||||||
|
FROM garfield.inventory_items
|
||||||
|
CROSS JOIN last_n_days
|
||||||
|
LEFT JOIN garfield.snack_sales_log
|
||||||
|
ON inventory_line = item_id
|
||||||
|
-- snack_sales_log has an index on snack_sales_log_timestamp to speed up this query
|
||||||
|
-- If that index doesn't exist the query takes much longer.
|
||||||
|
AND sale_date = date_trunc('day', snack_sales_log_timestamp AT TIME ZONE 'UTC+1')
|
||||||
|
WHERE available
|
||||||
|
GROUP BY item_id, sale_date
|
||||||
|
ORDER BY item_id, sale_date
|
||||||
|
)
|
||||||
|
SELECT item_id, array_agg(sales) AS last_n_days_sales
|
||||||
|
FROM beeg
|
||||||
|
GROUP BY item_id
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
DO $$
|
||||||
|
DECLARE new_item_id integer;
|
||||||
|
DECLARE new_snack_id integer;
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
-- Create a new inventory line
|
||||||
|
INSERT INTO garfield.inventory_items
|
||||||
|
(item_barcode, name, item_group, location, tax_group, sales_units, unit_price)
|
||||||
|
VALUES
|
||||||
|
(%(barcode)s, %(name)s, %(group_id)s, %(location_id)s, %(tax_group_id)s, %(sales_units)s, %(net_unit_price)s)
|
||||||
|
RETURNING item_id INTO new_item_id;
|
||||||
|
|
||||||
|
-- Delete (i.e. mark as inactive) any old snacks with the same barcode in the given location
|
||||||
|
PERFORM garfield.snack_delete(snack_id)
|
||||||
|
FROM garfield.snacks
|
||||||
|
WHERE snack_barcode = %(barcode)s
|
||||||
|
AND location_id = %(location_id)s;
|
||||||
|
|
||||||
|
-- Create a new snack entry...
|
||||||
|
SELECT garfield.snack_create(%(name)s, %(barcode)s, %(gross_unit_price)s, %(tax_group_id)s, %(location_id)s)
|
||||||
|
INTO new_snack_id;
|
||||||
|
|
||||||
|
-- ... and map it to the new inventory line
|
||||||
|
PERFORM garfield.inventory_map_snack(new_snack_id, new_item_id);
|
||||||
|
|
||||||
|
END$$
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
SELECT
|
SELECT
|
||||||
group_id,
|
group_id AS id,
|
||||||
group_name
|
group_name AS name
|
||||||
FROM garfield.inventory_item_groups
|
FROM garfield.inventory_item_groups
|
||||||
ORDER BY
|
ORDER BY
|
||||||
group_name ASC
|
group_name ASC
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
SELECT
|
||||||
|
location_id AS id,
|
||||||
|
location_name AS name
|
||||||
|
FROM garfield.locations
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
SELECT
|
||||||
|
tax_group_id AS id,
|
||||||
|
description,
|
||||||
|
tax_percentage :: float AS percentage,
|
||||||
|
tax_description AS description
|
||||||
|
FROM garfield.tax_groups,
|
||||||
|
LATERAL garfield.tax_find(tax_group_id, NOW() :: date) AS tax_id
|
||||||
|
LEFT JOIN garfield.taxes USING (tax_id)
|
||||||
|
WHERE active
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
FROM all_inventory_item_overview
|
FROM all_inventory_item_overview
|
||||||
|
LEFT JOIN more_recent_inventory_lines_with_same_barcode USING (item_id)
|
||||||
|
LEFT JOIN inventory_last_n_days_sales USING (item_id)
|
||||||
WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
|
WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
|
||||||
AND available
|
AND available
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- parameters:
|
||||||
|
--
|
||||||
|
-- location_id: Location to generate the report for
|
||||||
|
|
||||||
|
WITH
|
||||||
|
sales_by_item_id AS (
|
||||||
|
SELECT
|
||||||
|
inventory_line AS item_id,
|
||||||
|
max(snack_sales_log_timestamp) AS last_sold,
|
||||||
|
count(*)::int AS units_sold
|
||||||
|
FROM garfield.snack_sales_log
|
||||||
|
WHERE type_id = 'SNACK_BUY'
|
||||||
|
AND inventory_line IS NOT NULL
|
||||||
|
AND snack_sales_log_timestamp > NOW() - INTERVAL '120 days'
|
||||||
|
AND location_id = %(location_id)s
|
||||||
|
GROUP BY item_id
|
||||||
|
),
|
||||||
|
sales_by_barcode AS (
|
||||||
|
SELECT
|
||||||
|
item_barcode,
|
||||||
|
max(name) AS name,
|
||||||
|
sum(units_sold) AS units_sold,
|
||||||
|
round(avg((units_sold / extract(epoch FROM (CASE WHEN available THEN now() ELSE last_sold END) - bought)) * 86400)::numeric, 2) AS sold_per_day
|
||||||
|
FROM sales_by_item_id
|
||||||
|
LEFT JOIN garfield.inventory_items USING (item_id)
|
||||||
|
GROUP BY item_barcode
|
||||||
|
),
|
||||||
|
current_inventory AS (
|
||||||
|
SELECT
|
||||||
|
item_barcode,
|
||||||
|
sum(units_left)::int AS units_left
|
||||||
|
FROM all_inventory_item_overview
|
||||||
|
WHERE available
|
||||||
|
AND location = %(location_id)s
|
||||||
|
GROUP BY item_barcode
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
sales_by_barcode.*,
|
||||||
|
COALESCE(current_inventory.units_left, 0)::int AS units_left
|
||||||
|
FROM sales_by_barcode
|
||||||
|
LEFT JOIN current_inventory USING (item_barcode)
|
||||||
|
ORDER BY units_sold DESC
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
SELECT
|
||||||
|
item_barcode,
|
||||||
|
name,
|
||||||
|
unit_price :: float,
|
||||||
|
TO_CHAR(bought, 'YYYY-MM-DD') AS bought,
|
||||||
|
sales_units,
|
||||||
|
available,
|
||||||
|
location_name,
|
||||||
|
location as location_id,
|
||||||
|
group_name,
|
||||||
|
item_group AS group_id,
|
||||||
|
tax_group AS tax_group_id,
|
||||||
|
units_left :: integer
|
||||||
|
FROM all_inventory_item_overview
|
||||||
|
WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
|
||||||
|
AND (name ILIKE CONCAT('%%', %(search_term)s, '%%') OR item_barcode = %(search_term)s)
|
||||||
|
ORDER BY bought DESC
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
INSERT INTO garfield.inventory_correction (item_id, delta, correction_comment)
|
||||||
|
VALUES (%(from_item_id)s, -%(amount)s, CONCAT('Umbuchung auf ', %(to_item_id)s));
|
||||||
|
|
||||||
|
INSERT INTO garfield.inventory_correction (item_id, delta, correction_comment)
|
||||||
|
VALUES (%(to_item_id)s, %(amount)s, CONCAT('Umbuchung von ', %(from_item_id)s));
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"SECRET_KEY": "dev",
|
||||||
|
"DB_CONNECTION_STRING": "host=fsmi-db dbname=garfield"
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
from flask import Blueprint, flash, redirect, render_template, request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("entry", __name__, url_prefix="/entry")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["GET", "POST"])
|
||||||
|
def index():
|
||||||
|
return render_template(
|
||||||
|
"entry/index.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/add-new-items")
|
||||||
|
def add_new_entries():
|
||||||
|
i_know_what_im_doing = "i-know-what-im-doing" in request.form
|
||||||
|
if not i_know_what_im_doing:
|
||||||
|
return "Du weißt nicht was du tust", 400
|
||||||
|
|
||||||
|
orders = current_user.data.orders
|
||||||
|
if not orders:
|
||||||
|
return "Keine Aufträge", 404
|
||||||
|
|
||||||
|
# I'm aware of execute_many and extras.execute_values but we don't need to
|
||||||
|
# optimize here (yet?). This way it's a bit easier to use anyways.
|
||||||
|
for order in orders:
|
||||||
|
with db.run_query("entry/add_item_and_snack_entry.sql", order) as cursor:
|
||||||
|
pass
|
||||||
|
db.get_db().commit()
|
||||||
|
|
||||||
|
# Reset the cart
|
||||||
|
current_user.data.orders = []
|
||||||
|
|
||||||
|
return redirect(request.referrer)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/delete-order")
|
||||||
|
def delete_order():
|
||||||
|
try:
|
||||||
|
order_index = int(request.form["order-index"])
|
||||||
|
except:
|
||||||
|
return "Incomplete or mistyped form", 400
|
||||||
|
|
||||||
|
del current_user.data.orders[order_index]
|
||||||
|
|
||||||
|
return redirect(request.referrer)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/new-order", methods=["GET", "POST"])
|
||||||
|
def new_order():
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
barcode = request.form["barcode"]
|
||||||
|
name = request.form["name"]
|
||||||
|
sales_units = int(request.form["sales-units"])
|
||||||
|
group_id = int(request.form["group-id"])
|
||||||
|
# group_name, location_name and tax_group_description are not
|
||||||
|
# necessarily needed here but storing them makes it easier to
|
||||||
|
# render the list of orders.
|
||||||
|
group_name = request.form["group-name"]
|
||||||
|
location_id = int(request.form["location-id"])
|
||||||
|
location_name = request.form["location-name"]
|
||||||
|
tax_group_id = int(request.form["tax-group-id"])
|
||||||
|
tax_group_description = request.form["tax-group-description"]
|
||||||
|
net_unit_price = float(request.form["net-unit-price"])
|
||||||
|
gross_unit_price = float(request.form["gross-unit-price"])
|
||||||
|
except:
|
||||||
|
return f"Incomplete or mistyped form", 400
|
||||||
|
|
||||||
|
current_user.data.orders.append({
|
||||||
|
"barcode": barcode,
|
||||||
|
"name": name,
|
||||||
|
"sales_units": sales_units,
|
||||||
|
"group_id": group_id,
|
||||||
|
"group_name": group_name,
|
||||||
|
"location_id": location_id,
|
||||||
|
"location_name": location_name,
|
||||||
|
"tax_group_id": tax_group_id,
|
||||||
|
"tax_group_description": tax_group_description,
|
||||||
|
"net_unit_price": net_unit_price,
|
||||||
|
"gross_unit_price": gross_unit_price
|
||||||
|
})
|
||||||
|
return redirect("/entry")
|
||||||
|
|
||||||
|
with db.run_query("entry/get_groups.sql") as cursor:
|
||||||
|
groups = cursor.fetchall()
|
||||||
|
|
||||||
|
with db.run_query("entry/get_locations.sql") as cursor:
|
||||||
|
locations = cursor.fetchall()
|
||||||
|
|
||||||
|
with db.run_query("entry/get_tax_groups.sql") as cursor:
|
||||||
|
tax_groups = cursor.fetchall()
|
||||||
|
|
||||||
|
selected_location = current_user.data.location
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"entry/new-order.html",
|
||||||
|
groups=groups,
|
||||||
|
locations=locations,
|
||||||
|
default_location=next(location for location in locations if location["id"] == selected_location["location_id"]) if selected_location is not None else locations[0],
|
||||||
|
tax_groups=tax_groups
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# API routes for interactive JS stuff
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/api/search-items")
|
||||||
|
def api_search_items():
|
||||||
|
try:
|
||||||
|
search_term = request.args["search-term"]
|
||||||
|
except:
|
||||||
|
return {"error": "Missing query parameter `search-term`"}, 400
|
||||||
|
|
||||||
|
location = current_user.data.location
|
||||||
|
with db.run_query("search_items.sql", {
|
||||||
|
"location_id": location["location_id"] if location else None,
|
||||||
|
"search_term": search_term
|
||||||
|
}) as cursor:
|
||||||
|
items = cursor.fetchall()
|
||||||
|
|
||||||
|
return items
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from flask import Blueprint, redirect, render_template, request, session
|
from flask import Blueprint, redirect, render_template, request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
|
||||||
@@ -6,9 +7,9 @@ from . import db
|
|||||||
bp = Blueprint("inventory", __name__, url_prefix="/inventory")
|
bp = Blueprint("inventory", __name__, url_prefix="/inventory")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.get("/")
|
||||||
def index():
|
def index():
|
||||||
location = session.get("location", None)
|
location = current_user.data.location
|
||||||
items = db.run_query("get_inventory_overview.sql", {
|
items = db.run_query("get_inventory_overview.sql", {
|
||||||
"location_id": None if location is None else location["location_id"]
|
"location_id": None if location is None else location["location_id"]
|
||||||
}).fetchall()
|
}).fetchall()
|
||||||
@@ -18,6 +19,24 @@ def index():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/report")
|
||||||
|
def read_report():
|
||||||
|
location = current_user.data.location
|
||||||
|
|
||||||
|
if location is None:
|
||||||
|
# TODO: Error handling
|
||||||
|
return "please select a location in order to generate a report", 400
|
||||||
|
|
||||||
|
items = db.run_query("get_inventory_report.sql", {
|
||||||
|
"location_id": location["location_id"]
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
|
return render_template("inventory/read_report.html", **{
|
||||||
|
"location": location,
|
||||||
|
"items": items
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/item/<item_id>")
|
@bp.get("/item/<item_id>")
|
||||||
def read_item(item_id: int):
|
def read_item(item_id: int):
|
||||||
item = db.run_query("get_item_by_id.sql", {
|
item = db.run_query("get_item_by_id.sql", {
|
||||||
@@ -80,3 +99,28 @@ def create_correction():
|
|||||||
db.get_db().commit()
|
db.get_db().commit()
|
||||||
|
|
||||||
return redirect(request.referrer)
|
return redirect(request.referrer)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/transfer")
|
||||||
|
def transfer_items():
|
||||||
|
try:
|
||||||
|
from_item_id = int(request.form.get("from_item_id"))
|
||||||
|
to_item_id = int(request.form.get("to_item_id"))
|
||||||
|
amount = int(request.form.get("amount"))
|
||||||
|
except:
|
||||||
|
return "Incomplete or mistyped form", 400
|
||||||
|
|
||||||
|
if amount == 0:
|
||||||
|
return "Amount may not be 0", 400
|
||||||
|
|
||||||
|
if from_item_id == to_item_id:
|
||||||
|
return "Transfers must be between different items", 400
|
||||||
|
|
||||||
|
db.run_query("transfer_items.sql", {
|
||||||
|
"from_item_id": from_item_id,
|
||||||
|
"to_item_id": to_item_id,
|
||||||
|
"amount": amount
|
||||||
|
})
|
||||||
|
db.get_db().commit()
|
||||||
|
|
||||||
|
return redirect(request.referrer)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from flask import Blueprint, render_template, request, session
|
from flask import Blueprint, render_template, request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
|
||||||
@@ -11,13 +12,12 @@ def index():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
location_id = request.form.get("location_id", "")
|
location_id = request.form.get("location_id", "")
|
||||||
if location_id == "":
|
if location_id == "":
|
||||||
session.pop("location", None)
|
current_user.data.location = None
|
||||||
else:
|
else:
|
||||||
location = db.run_query("get_location_by_id.sql", {
|
location = db.run_query("get_location_by_id.sql", {
|
||||||
"location_id": location_id}
|
"location_id": location_id
|
||||||
).fetchone()
|
}).fetchone()
|
||||||
session["location"] = location
|
current_user.data.location = location
|
||||||
|
|
||||||
|
|
||||||
locations = db.run_query("get_locations.sql").fetchall()
|
locations = db.run_query("get_locations.sql").fetchall()
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
html {
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
nav > ul {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
nav > ul > li {
|
||||||
|
display: inline-block;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
nav > ul > li + li:before {
|
||||||
|
content: ' · ';
|
||||||
|
}
|
||||||
|
.current-page > a {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.current-page > a:after {
|
||||||
|
content: '↓';
|
||||||
|
font-size: 0.8em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
right: 50%;
|
||||||
|
top: -1em;
|
||||||
|
width: 1em;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: -0.5em;
|
||||||
|
animation: wiggle 0.8s ease-in-out 0s infinite;
|
||||||
|
/* animation-direction: alternate; */
|
||||||
|
}
|
||||||
|
.--align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.--align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.--centered {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.--not-important {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
@keyframes wiggle {
|
||||||
|
0%, 100% { margin-top: 0; }
|
||||||
|
50% { margin-top: -0.5em; }
|
||||||
|
/* 100% { transform: rotate(1turn); } */
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-spacing: .5em 0;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-size: .8em;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
/* hide the menu when printing */
|
||||||
|
header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.form-input > label {
|
||||||
|
font-size: .8em;
|
||||||
|
}
|
||||||
|
.form-input > input:not([type=radio]),
|
||||||
|
.form-input > select {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.form-input.--inline {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.form-input.--inline > input:not([type=radio]),
|
||||||
|
.form-input.--inline > select {
|
||||||
|
display: block;
|
||||||
|
width: 8em;
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
.consumption-graph {
|
||||||
|
display: block;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def format_currency(x):
|
||||||
|
# It would be nicer to format this using the German locale
|
||||||
|
# Too lazy to bother tho.
|
||||||
|
return f"{x:.02f}€".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(d):
|
||||||
|
return d.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def format_bool(x):
|
||||||
|
return "✅" if x else "❌"
|
||||||
|
|
||||||
|
|
||||||
|
def to_json(x):
|
||||||
|
return json.dumps(x)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>jon · not authenticated</title>
|
||||||
|
<link rel="stylesheet" href="/static/jon.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>jon</h1>
|
||||||
|
|
||||||
|
{% if config.DEBUG %}
|
||||||
|
<details>
|
||||||
|
<summary><code>config</code></summary>
|
||||||
|
<pre>{% for key, value in config.items() %}{{ key }} = {{ value }}
|
||||||
|
{% endfor %}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<p>
|
||||||
|
Damit kein Schabernack getrieben wird müssen wir sicherstellen, dass du die Person bist die jon ausgeführt hat.
|
||||||
|
Gib unten das Token ein, welches jon beim Starten ausgegeben hat.
|
||||||
|
</p>
|
||||||
|
<form method="GET">
|
||||||
|
<div class="form-input">
|
||||||
|
<label for="token">Token</label>
|
||||||
|
<input type="password" name="token" placeholder="Token" id="token">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Authentifizieren</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>jon</title>
|
||||||
|
<link rel="stylesheet" href="/static/jon.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>jon</h1>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li{{ " class=current-page" if request.path == "/" else "" }}><a href="/">Home</a></li>
|
||||||
|
<li{{ " class=current-page" if request.path.startswith("/inventory") else "" }}><a href="/inventory">Inventar</a></li>
|
||||||
|
<li><a href="/inventory/report">Einkäuferbericht</a></li>
|
||||||
|
<li{{ " class=current-page" if request.path.startswith("/entry") else "" }}><a href="/entry">Eintragen</a></li>
|
||||||
|
<li{{ " class=current-page" if request.path.startswith("/location") else "" }}>
|
||||||
|
<a href="/location">
|
||||||
|
{% if not current_user.data.location %}
|
||||||
|
Raum wählen
|
||||||
|
{% else %}
|
||||||
|
Raum: {{ current_user.data.location.location_name }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li{{ " class=current-page" if request.path.startswith("/auth/logout") else "" }}><a href="/auth/logout">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if config.DEBUG %}
|
||||||
|
<details>
|
||||||
|
<summary><code>config</code></summary>
|
||||||
|
<pre>{% for key, value in config.items() %}{{ key }} = {{ value }}
|
||||||
|
{% endfor %}</pre>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><code>session</code></summary>
|
||||||
|
<pre>{% for key, value in session.items() %}{{ key }} = {{ value }}
|
||||||
|
{% endfor %}</pre>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><code>current_user.data</code></summary>
|
||||||
|
<pre>{% for key, value in current_user.data.__dict__.items() %}{{ key }} = {{ value }}
|
||||||
|
{% endfor %}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a href="/entry/new-order">Neuer Auftrag</a>
|
||||||
|
<h2>Aufträge</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Barcode</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Eingekauft</th>
|
||||||
|
<th>Gruppe</th>
|
||||||
|
<th>Raum</th>
|
||||||
|
<th>Steuergruppe</th>
|
||||||
|
<th>EK-Preis (Netto)</th>
|
||||||
|
<th>VK-Preis (Brutto)</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
{% for cart_item in current_user.data.orders %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ cart_item.barcode }}</code></td>
|
||||||
|
<td>{{ cart_item.name }}</td>
|
||||||
|
<td class="--align-right">{{ cart_item.sales_units }}</td>
|
||||||
|
<td class="--align-right">{{ cart_item.group_name }} ({{ cart_item.group_id }})</td>
|
||||||
|
<td class="--align-right">{{ cart_item.location_name }} ({{ cart_item.location_id }})</td>
|
||||||
|
<td class="--align-right">{{ cart_item.tax_group_description }} ({{ cart_item.tax_group_id }})</td>
|
||||||
|
<td class="--align-right">{{ format_currency(cart_item.net_unit_price) }}</td>
|
||||||
|
<td class="--align-right">{{ format_currency(cart_item.gross_unit_price) }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/entry/delete-order">
|
||||||
|
<input type="hidden" name="order-index" value="{{ loop.index0 }}">
|
||||||
|
<button>Löschen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<form method="POST" action="/entry/add-new-items">
|
||||||
|
<input type="checkbox" name="i-know-what-im-doing" id="i-know-what-im-doing">
|
||||||
|
<label for="i-know-what-im-doing">I weiß, was ich tue</label>
|
||||||
|
<button>Neue Einträge anlegen</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="entry-app"></div>
|
||||||
|
<script src="{{ url_for('static', filename='entry.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
Elm.Entry.init({
|
||||||
|
node: document.querySelector('.entry-app'),
|
||||||
|
flags: {
|
||||||
|
locations: {{ to_json(locations) | safe }},
|
||||||
|
defaultLocation: {{ to_json(default_location) | safe }},
|
||||||
|
groups: {{ to_json(groups) | safe }},
|
||||||
|
defaultGroup: {{ to_json(groups[0]) | safe }},
|
||||||
|
taxGroups: {{ to_json(tax_groups) | safe }},
|
||||||
|
defaultTaxGroup: {{ to_json(tax_groups[0]) | safe }},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% macro consumption_graph_svg(values) -%}
|
||||||
|
{% set stroke_width = 8 %}
|
||||||
|
{% set width = 300 %}
|
||||||
|
{% set height = 100 %}
|
||||||
|
{% set padding = 4 %}
|
||||||
|
{% set dx = (width - 2 * padding) / ((values | length) + 1) %}
|
||||||
|
{% set dy = (height - 2 * padding) / ((values + [1]) | max) %}
|
||||||
|
<svg viewBox="0 0 {{ width }} {{ height }}" role="img" class="consumption-graph">
|
||||||
|
<polyline
|
||||||
|
points="
|
||||||
|
{% for value in values %}
|
||||||
|
{{ padding + loop.index * dx }}, {{ height - padding - value * dy }}
|
||||||
|
{% endfor %}
|
||||||
|
"
|
||||||
|
stroke="green"
|
||||||
|
stroke-width="{{ stroke_width }}"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
</svg>
|
||||||
|
{% endmacro -%}
|
||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Stat</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Graph</th>
|
||||||
|
<th>Barcode</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Preis (Netto)</th>
|
||||||
|
<th>Kaufdatum</th>
|
||||||
|
<th>Gruppe</th>
|
||||||
|
<th>Eingekauft</th>
|
||||||
|
<th title="Korrekturen">Korr.</th>
|
||||||
|
<th>Inventar</th>
|
||||||
|
<th title="Anzahl aktiver Snackeinträge">#AS</th>
|
||||||
|
<th>Raum</th>
|
||||||
|
</tr>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if item.units_left == 0 %}<span title="Leerer aktiver Inventareintrag">🅾️</span>{% endif %}
|
||||||
|
{% if item.other_lines_count != 0 %}<span title="{% if item.other_lines_count == 1 %}Neuerer Eintrag mit demselben Barcode ist aktiv{% else %}{{ item.other_lines_count }} Einträge mit demselben Barcode sind aktiv{% endif %}">🔄</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><a href="/inventory/item/{{ item.item_id }}">{{ item.item_id }}</a></td>
|
||||||
|
<td>{{ consumption_graph_svg(item.last_n_days_sales) }}</td>
|
||||||
|
<td><code>{{ item.item_barcode }}</code></td>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td class="--align-right">{{ format_currency(item.unit_price) }}</td>
|
||||||
|
<td>{{ format_date(item.bought) }}</td>
|
||||||
|
<td>{{ item.group_name }} ({{ item.item_group }})</td>
|
||||||
|
<td class="--align-right">{{ item.sales_units }}</td>
|
||||||
|
<td class="--align-right">{% if item.correction_delta > 0 %}+{% endif %}{{ item.correction_delta }}</td>
|
||||||
|
<td class="--align-right">{{ item.units_left }}</td>
|
||||||
|
<td class="--align-right">{{ item.active_mappings }}</td>
|
||||||
|
<td>{{ item.location_name }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
+22
-15
@@ -22,8 +22,7 @@
|
|||||||
<th class="--align-left">Einkaufspreis (Netto)</th>
|
<th class="--align-left">Einkaufspreis (Netto)</th>
|
||||||
<td>{{ format_currency(item.unit_price) }}</td>
|
<td>{{ format_currency(item.unit_price) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr> <th class="--align-left">Kaufdatum</th>
|
||||||
<th class="--align-left">Kaufdatum</th>
|
|
||||||
<td>{{ format_date(item.bought) }}</td>
|
<td>{{ format_date(item.bought) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -121,25 +120,33 @@
|
|||||||
<th title="Aktive Snackeinträge">AS</th>
|
<th title="Aktive Snackeinträge">AS</th>
|
||||||
<th>Aktiv?</th>
|
<th>Aktiv?</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for item in same_barcode_items %}
|
{% for other_item in same_barcode_items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/inventory/item/{{ item.item_id }}">{{ item.item_id }}</a></td>
|
<td><a href="/inventory/item/{{ other_item.item_id }}">{{ other_item.item_id }}</a></td>
|
||||||
<td><code>{{ item.item_barcode }}</code></td>
|
<td><code>{{ other_item.item_barcode }}</code></td>
|
||||||
<td>{{ item.name }}</td>
|
<td>{{ other_item.name }}</td>
|
||||||
<td class="--align-right">{{ format_currency(item.unit_price) }}</td>
|
<td class="--align-right">{{ format_currency(other_item.unit_price) }}</td>
|
||||||
<td>{{ format_date(item.bought) }}</td>
|
<td>{{ format_date(other_item.bought) }}</td>
|
||||||
<td>{{ item.group_name }} ({{ item.item_group }})</td>
|
<td>{{ other_item.group_name }} ({{ other_item.item_group }})</td>
|
||||||
<td class="--align-right">{{ item.sales_units }}</td>
|
<td class="--align-right">{{ other_item.sales_units }}</td>
|
||||||
<td class="--align-right">{% if item.correction_delta > 0 %}+{% endif %}{{ item.correction_delta }}</td>
|
<td class="--align-right">{% if other_item.correction_delta > 0 %}+{% endif %}{{ other_item.correction_delta }}</td>
|
||||||
<td class="--align-right">{{ item.units_left }}</td>
|
<td class="--align-right">{{ other_item.units_left }}</td>
|
||||||
<td class="--centered">
|
<td class="--centered">
|
||||||
{% if item.active_mappings != 0 %}
|
{% if other_item.active_mappings != 0 %}
|
||||||
{{ item.active_mappings_array | join(", ") }}
|
{{ other_item.active_mappings_array | join(", ") }}
|
||||||
{% else %}
|
{% else %}
|
||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="--centered">{{ format_bool(item.available) }}</td>
|
<td class="--centered">{{ format_bool(other_item.available) }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/inventory/transfer">
|
||||||
|
<input type="hidden" name="from_item_id" value="{{ item.item_id }}">
|
||||||
|
<input type="hidden" name="to_item_id" value="{{ other_item.item_id }}">
|
||||||
|
<input type="hidden" name="amount" value="{{ item.units_left }}">
|
||||||
|
<button{% if not other_item.available or other_item.item_id == item.item_id %} disabled{% endif %}>{{ item.units_left }} Artikel umbuchen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Einkäuferbericht für {{ location.location_name }}</h2>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Barcode</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Inventar</th>
|
||||||
|
<th title="In den letzten 4 Monaten verkauft">Verkauft</th>
|
||||||
|
<th>Verbrauch [1/d]</th>
|
||||||
|
<th>Verbrauch [1/60d]</th>
|
||||||
|
<!--
|
||||||
|
<th title="Estimated Time Until Empty">ETUE [d]</th>
|
||||||
|
<th>Für 2m</th>
|
||||||
|
-->
|
||||||
|
</tr>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr{% if item.units_left >= item.units_sold %} class="--not-important"{% endif %}>
|
||||||
|
<td><code>{{ item.item_barcode }}</code></td>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td class="--align-right">{{ item.units_left }}</td>
|
||||||
|
<td class="--align-right">{{ item.units_sold }}</td>
|
||||||
|
<td class="--align-right">{{ item.sold_per_day }}</td>
|
||||||
|
<td class="--align-right">{{ item.sold_per_day * 60 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="POST" action=".">
|
<form method="POST">
|
||||||
<select name="location_id">
|
<select name="location_id">
|
||||||
<option value="" {{ "selected" if "location" not in session else ""}}>-</option>
|
<option value="" {{ "selected" if not current_user.data.location else ""}}>-</option>
|
||||||
{% for location in locations %}
|
{% for location in locations %}
|
||||||
<option value="{{ location.location_id }}" {{ "selected" if "location" in session and session.location.location_id == location.location_id else "" }}>{{ location.location_name }}</option>
|
<option value="{{ location.location_id }}" {{ "selected" if current_user.data.location.location_id == location.location_id else "" }}>{{ location.location_name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pkgs:
|
|
||||||
pkgs.haskell.packages.ghc924
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
haskellPackages: with haskellPackages; [
|
|
||||||
aeson
|
|
||||||
beam-core
|
|
||||||
beam-postgres
|
|
||||||
bytestring
|
|
||||||
lens
|
|
||||||
postgresql-simple
|
|
||||||
servant
|
|
||||||
servant-server
|
|
||||||
servant-swagger
|
|
||||||
servant-swagger-ui
|
|
||||||
scientific
|
|
||||||
swagger2
|
|
||||||
text
|
|
||||||
time
|
|
||||||
warp
|
|
||||||
]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{ overlays ? [] }:
|
|
||||||
let
|
|
||||||
sources = import ./sources.nix;
|
|
||||||
in
|
|
||||||
import sources.nixpkgs { inherit overlays; }
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"niv": {
|
|
||||||
"branch": "master",
|
|
||||||
"description": "Easy dependency management for Nix projects",
|
|
||||||
"homepage": "https://github.com/nmattia/niv",
|
|
||||||
"owner": "nmattia",
|
|
||||||
"repo": "niv",
|
|
||||||
"rev": "351d8bc316bf901a81885bab5f52687ec8ccab6e",
|
|
||||||
"sha256": "1yzhz7ihkh6p2sxhp3amqfbmm2yqzaadqqii1xijymvl8alw5rrr",
|
|
||||||
"type": "tarball",
|
|
||||||
"url": "https://github.com/nmattia/niv/archive/351d8bc316bf901a81885bab5f52687ec8ccab6e.tar.gz",
|
|
||||||
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"branch": "nixpkgs-unstable",
|
|
||||||
"description": "Nix Packages collection",
|
|
||||||
"homepage": "",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "227de2b3bbec142f912c09d5e8a1b4e778aa54fb",
|
|
||||||
"sha256": "04is77q4msyqi51q8zxialyl378hzv47ldml5hnycg42zvnzpi24",
|
|
||||||
"type": "tarball",
|
|
||||||
"url": "https://github.com/NixOS/nixpkgs/archive/227de2b3bbec142f912c09d5e8a1b4e778aa54fb.tar.gz",
|
|
||||||
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-194
@@ -1,194 +0,0 @@
|
|||||||
# This file has been generated by Niv.
|
|
||||||
|
|
||||||
let
|
|
||||||
|
|
||||||
#
|
|
||||||
# The fetchers. fetch_<type> fetches specs of type <type>.
|
|
||||||
#
|
|
||||||
|
|
||||||
fetch_file = pkgs: name: spec:
|
|
||||||
let
|
|
||||||
name' = sanitizeName name + "-src";
|
|
||||||
in
|
|
||||||
if spec.builtin or true then
|
|
||||||
builtins_fetchurl { inherit (spec) url sha256; name = name'; }
|
|
||||||
else
|
|
||||||
pkgs.fetchurl { inherit (spec) url sha256; name = name'; };
|
|
||||||
|
|
||||||
fetch_tarball = pkgs: name: spec:
|
|
||||||
let
|
|
||||||
name' = sanitizeName name + "-src";
|
|
||||||
in
|
|
||||||
if spec.builtin or true then
|
|
||||||
builtins_fetchTarball { name = name'; inherit (spec) url sha256; }
|
|
||||||
else
|
|
||||||
pkgs.fetchzip { name = name'; inherit (spec) url sha256; };
|
|
||||||
|
|
||||||
fetch_git = name: spec:
|
|
||||||
let
|
|
||||||
ref =
|
|
||||||
if spec ? ref then spec.ref else
|
|
||||||
if spec ? branch then "refs/heads/${spec.branch}" else
|
|
||||||
if spec ? tag then "refs/tags/${spec.tag}" else
|
|
||||||
abort "In git source '${name}': Please specify `ref`, `tag` or `branch`!";
|
|
||||||
submodules = if spec ? submodules then spec.submodules else false;
|
|
||||||
submoduleArg =
|
|
||||||
let
|
|
||||||
nixSupportsSubmodules = builtins.compareVersions builtins.nixVersion "2.4" >= 0;
|
|
||||||
emptyArgWithWarning =
|
|
||||||
if submodules == true
|
|
||||||
then
|
|
||||||
builtins.trace
|
|
||||||
(
|
|
||||||
"The niv input \"${name}\" uses submodules "
|
|
||||||
+ "but your nix's (${builtins.nixVersion}) builtins.fetchGit "
|
|
||||||
+ "does not support them"
|
|
||||||
)
|
|
||||||
{}
|
|
||||||
else {};
|
|
||||||
in
|
|
||||||
if nixSupportsSubmodules
|
|
||||||
then { inherit submodules; }
|
|
||||||
else emptyArgWithWarning;
|
|
||||||
in
|
|
||||||
builtins.fetchGit
|
|
||||||
({ url = spec.repo; inherit (spec) rev; inherit ref; } // submoduleArg);
|
|
||||||
|
|
||||||
fetch_local = spec: spec.path;
|
|
||||||
|
|
||||||
fetch_builtin-tarball = name: throw
|
|
||||||
''[${name}] The niv type "builtin-tarball" is deprecated. You should instead use `builtin = true`.
|
|
||||||
$ niv modify ${name} -a type=tarball -a builtin=true'';
|
|
||||||
|
|
||||||
fetch_builtin-url = name: throw
|
|
||||||
''[${name}] The niv type "builtin-url" will soon be deprecated. You should instead use `builtin = true`.
|
|
||||||
$ niv modify ${name} -a type=file -a builtin=true'';
|
|
||||||
|
|
||||||
#
|
|
||||||
# Various helpers
|
|
||||||
#
|
|
||||||
|
|
||||||
# https://github.com/NixOS/nixpkgs/pull/83241/files#diff-c6f540a4f3bfa4b0e8b6bafd4cd54e8bR695
|
|
||||||
sanitizeName = name:
|
|
||||||
(
|
|
||||||
concatMapStrings (s: if builtins.isList s then "-" else s)
|
|
||||||
(
|
|
||||||
builtins.split "[^[:alnum:]+._?=-]+"
|
|
||||||
((x: builtins.elemAt (builtins.match "\\.*(.*)" x) 0) name)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
# The set of packages used when specs are fetched using non-builtins.
|
|
||||||
mkPkgs = sources: system:
|
|
||||||
let
|
|
||||||
sourcesNixpkgs =
|
|
||||||
import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) { inherit system; };
|
|
||||||
hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath;
|
|
||||||
hasThisAsNixpkgsPath = <nixpkgs> == ./.;
|
|
||||||
in
|
|
||||||
if builtins.hasAttr "nixpkgs" sources
|
|
||||||
then sourcesNixpkgs
|
|
||||||
else if hasNixpkgsPath && ! hasThisAsNixpkgsPath then
|
|
||||||
import <nixpkgs> {}
|
|
||||||
else
|
|
||||||
abort
|
|
||||||
''
|
|
||||||
Please specify either <nixpkgs> (through -I or NIX_PATH=nixpkgs=...) or
|
|
||||||
add a package called "nixpkgs" to your sources.json.
|
|
||||||
'';
|
|
||||||
|
|
||||||
# The actual fetching function.
|
|
||||||
fetch = pkgs: name: spec:
|
|
||||||
|
|
||||||
if ! builtins.hasAttr "type" spec then
|
|
||||||
abort "ERROR: niv spec ${name} does not have a 'type' attribute"
|
|
||||||
else if spec.type == "file" then fetch_file pkgs name spec
|
|
||||||
else if spec.type == "tarball" then fetch_tarball pkgs name spec
|
|
||||||
else if spec.type == "git" then fetch_git name spec
|
|
||||||
else if spec.type == "local" then fetch_local spec
|
|
||||||
else if spec.type == "builtin-tarball" then fetch_builtin-tarball name
|
|
||||||
else if spec.type == "builtin-url" then fetch_builtin-url name
|
|
||||||
else
|
|
||||||
abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}";
|
|
||||||
|
|
||||||
# If the environment variable NIV_OVERRIDE_${name} is set, then use
|
|
||||||
# the path directly as opposed to the fetched source.
|
|
||||||
replace = name: drv:
|
|
||||||
let
|
|
||||||
saneName = stringAsChars (c: if isNull (builtins.match "[a-zA-Z0-9]" c) then "_" else c) name;
|
|
||||||
ersatz = builtins.getEnv "NIV_OVERRIDE_${saneName}";
|
|
||||||
in
|
|
||||||
if ersatz == "" then drv else
|
|
||||||
# this turns the string into an actual Nix path (for both absolute and
|
|
||||||
# relative paths)
|
|
||||||
if builtins.substring 0 1 ersatz == "/" then /. + ersatz else /. + builtins.getEnv "PWD" + "/${ersatz}";
|
|
||||||
|
|
||||||
# Ports of functions for older nix versions
|
|
||||||
|
|
||||||
# a Nix version of mapAttrs if the built-in doesn't exist
|
|
||||||
mapAttrs = builtins.mapAttrs or (
|
|
||||||
f: set: with builtins;
|
|
||||||
listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set))
|
|
||||||
);
|
|
||||||
|
|
||||||
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295
|
|
||||||
range = first: last: if first > last then [] else builtins.genList (n: first + n) (last - first + 1);
|
|
||||||
|
|
||||||
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257
|
|
||||||
stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1));
|
|
||||||
|
|
||||||
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269
|
|
||||||
stringAsChars = f: s: concatStrings (map f (stringToCharacters s));
|
|
||||||
concatMapStrings = f: list: concatStrings (map f list);
|
|
||||||
concatStrings = builtins.concatStringsSep "";
|
|
||||||
|
|
||||||
# https://github.com/NixOS/nixpkgs/blob/8a9f58a375c401b96da862d969f66429def1d118/lib/attrsets.nix#L331
|
|
||||||
optionalAttrs = cond: as: if cond then as else {};
|
|
||||||
|
|
||||||
# fetchTarball version that is compatible between all the versions of Nix
|
|
||||||
builtins_fetchTarball = { url, name ? null, sha256 }@attrs:
|
|
||||||
let
|
|
||||||
inherit (builtins) lessThan nixVersion fetchTarball;
|
|
||||||
in
|
|
||||||
if lessThan nixVersion "1.12" then
|
|
||||||
fetchTarball ({ inherit url; } // (optionalAttrs (!isNull name) { inherit name; }))
|
|
||||||
else
|
|
||||||
fetchTarball attrs;
|
|
||||||
|
|
||||||
# fetchurl version that is compatible between all the versions of Nix
|
|
||||||
builtins_fetchurl = { url, name ? null, sha256 }@attrs:
|
|
||||||
let
|
|
||||||
inherit (builtins) lessThan nixVersion fetchurl;
|
|
||||||
in
|
|
||||||
if lessThan nixVersion "1.12" then
|
|
||||||
fetchurl ({ inherit url; } // (optionalAttrs (!isNull name) { inherit name; }))
|
|
||||||
else
|
|
||||||
fetchurl attrs;
|
|
||||||
|
|
||||||
# Create the final "sources" from the config
|
|
||||||
mkSources = config:
|
|
||||||
mapAttrs (
|
|
||||||
name: spec:
|
|
||||||
if builtins.hasAttr "outPath" spec
|
|
||||||
then abort
|
|
||||||
"The values in sources.json should not have an 'outPath' attribute"
|
|
||||||
else
|
|
||||||
spec // { outPath = replace name (fetch config.pkgs name spec); }
|
|
||||||
) config.sources;
|
|
||||||
|
|
||||||
# The "config" used by the fetchers
|
|
||||||
mkConfig =
|
|
||||||
{ sourcesFile ? if builtins.pathExists ./sources.json then ./sources.json else null
|
|
||||||
, sources ? if isNull sourcesFile then {} else builtins.fromJSON (builtins.readFile sourcesFile)
|
|
||||||
, system ? builtins.currentSystem
|
|
||||||
, pkgs ? mkPkgs sources system
|
|
||||||
}: rec {
|
|
||||||
# The sources, i.e. the attribute set of spec name to spec
|
|
||||||
inherit sources;
|
|
||||||
|
|
||||||
# The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers
|
|
||||||
inherit pkgs;
|
|
||||||
};
|
|
||||||
|
|
||||||
in
|
|
||||||
mkSources (mkConfig {}) // { __functor = _: settings: mkSources (mkConfig settings); }
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Adapted from new-template.hsfiles
|
|
||||||
|
|
||||||
name: jon
|
|
||||||
version: 0.1.0.0
|
|
||||||
git: "https://git.fsmi.org/paul/jon"
|
|
||||||
license: MIT
|
|
||||||
author: "Paul Brinkmeier"
|
|
||||||
maintainer: "paul.brinkmeier@fsmi.uni-karlsruhe.de"
|
|
||||||
copyright: "2022 Paul Brinkmeier"
|
|
||||||
|
|
||||||
extra-source-files:
|
|
||||||
- README.md
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
- base >= 4.7 && < 5
|
|
||||||
- aeson
|
|
||||||
- beam-core
|
|
||||||
- beam-postgres
|
|
||||||
- bytestring
|
|
||||||
- lens
|
|
||||||
- postgresql-simple
|
|
||||||
- servant
|
|
||||||
- servant-server
|
|
||||||
- servant-swagger
|
|
||||||
- servant-swagger-ui
|
|
||||||
- scientific
|
|
||||||
- swagger2
|
|
||||||
- text
|
|
||||||
- time
|
|
||||||
- warp
|
|
||||||
|
|
||||||
ghc-options:
|
|
||||||
- -Wall
|
|
||||||
- -Wcompat
|
|
||||||
- -Widentities
|
|
||||||
- -Wincomplete-record-updates
|
|
||||||
- -Wincomplete-uni-patterns
|
|
||||||
- -Wmissing-export-lists
|
|
||||||
- -Wmissing-home-modules
|
|
||||||
- -Wpartial-fields
|
|
||||||
- -Wredundant-constraints
|
|
||||||
|
|
||||||
library:
|
|
||||||
source-dirs: src
|
|
||||||
|
|
||||||
executables:
|
|
||||||
jon-exe:
|
|
||||||
main: Main.hs
|
|
||||||
source-dirs: app
|
|
||||||
ghc-options:
|
|
||||||
- -threaded
|
|
||||||
- -rtsopts
|
|
||||||
- -with-rtsopts=-N
|
|
||||||
dependencies:
|
|
||||||
- jon
|
|
||||||
# Fix "Multiple files use the same module name", see
|
|
||||||
# https://stackoverflow.com/questions/67519851/multiple-files-use-the-same-module-name
|
|
||||||
when:
|
|
||||||
- condition: false
|
|
||||||
other-modules: Paths_jon
|
|
||||||
|
|
||||||
tests:
|
|
||||||
jon-test:
|
|
||||||
main: Spec.hs
|
|
||||||
source-dirs: test
|
|
||||||
ghc-options:
|
|
||||||
- -threaded
|
|
||||||
- -rtsopts
|
|
||||||
- -with-rtsopts=-N
|
|
||||||
dependencies:
|
|
||||||
- jon
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
- [ ] Fix date handling in entry
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import inspect
|
|
||||||
|
|
||||||
from flask import Flask, render_template
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
db,
|
|
||||||
entry,
|
|
||||||
inventory,
|
|
||||||
location,
|
|
||||||
template_utils
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.config.from_mapping(
|
|
||||||
SECRET_KEY="dev"
|
|
||||||
)
|
|
||||||
|
|
||||||
db.init_app(app)
|
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def utility_processor():
|
|
||||||
return dict(inspect.getmembers(template_utils, inspect.isfunction))
|
|
||||||
|
|
||||||
app.register_blueprint(location.bp)
|
|
||||||
app.register_blueprint(inventory.bp)
|
|
||||||
app.register_blueprint(entry.bp)
|
|
||||||
@app.route("/")
|
|
||||||
def index():
|
|
||||||
return render_template("index.html")
|
|
||||||
|
|
||||||
return app
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import zoneinfo
|
|
||||||
|
|
||||||
|
|
||||||
from flask import Blueprint, redirect, render_template, request, session
|
|
||||||
|
|
||||||
from . import db
|
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("entry", __name__, url_prefix="/entry")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
|
||||||
def index():
|
|
||||||
return render_template("entry/index.html")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/edit-item-data", methods=["GET", "POST"])
|
|
||||||
def edit_item_data():
|
|
||||||
if "entry" not in session:
|
|
||||||
session["entry"] = dict()
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
session["entry"] = {
|
|
||||||
"item_bought": datetime.datetime.strptime(request.form.get("item_bought"), "%Y-%m-%d"),
|
|
||||||
"item_barcode": request.form.get("item_barcode"),
|
|
||||||
"item_name": request.form.get("item_name"),
|
|
||||||
"item_group_id": int(request.form.get("item_group")),
|
|
||||||
"item_net_unit_price": float(request.form.get("item_net_unit_price")),
|
|
||||||
"item_tax_group_id": int(request.form.get("item_tax_group")),
|
|
||||||
"item_amount": int(request.form.get("item_amount")),
|
|
||||||
"item_location_id": int(request.form.get("item_location"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect("/entry/select-snack-entry")
|
|
||||||
|
|
||||||
groups = db.run_query("get_groups.sql").fetchall()
|
|
||||||
locations = db.run_query("get_locations.sql").fetchall()
|
|
||||||
|
|
||||||
return render_template("entry/edit-item-data.html", **{
|
|
||||||
"groups": groups,
|
|
||||||
"locations": locations,
|
|
||||||
"entry": session["entry"]
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/select-snack-entry", methods=["GET", "POST"])
|
|
||||||
def edit_snack_data():
|
|
||||||
if "entry" not in session:
|
|
||||||
return redirect("/entry/edit-item-data")
|
|
||||||
|
|
||||||
snacks = db.run_query("get_snacks_by_barcode.sql", {
|
|
||||||
"snack_barcode": session["entry"]["item_barcode"]
|
|
||||||
}).fetchall()
|
|
||||||
|
|
||||||
return render_template("entry/select-snack-entry.html", **{
|
|
||||||
"entry": session["entry"],
|
|
||||||
"snacks": snacks
|
|
||||||
})
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import datetime
|
|
||||||
|
|
||||||
|
|
||||||
def format_currency(x):
|
|
||||||
# It would be nicer to format this using the German locale
|
|
||||||
# Too lazy to bother tho.
|
|
||||||
return f"{x:.02f}€".replace(".", ",")
|
|
||||||
|
|
||||||
|
|
||||||
def format_date(d):
|
|
||||||
return d.strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
def format_bool(x):
|
|
||||||
return "✅" if x else "❌"
|
|
||||||
|
|
||||||
|
|
||||||
def now():
|
|
||||||
return datetime.datetime.now()
|
|
||||||
|
|
||||||
|
|
||||||
def get_garfield_price(net_unit_price, tax_group_id):
|
|
||||||
if tax_group_id == 1:
|
|
||||||
tax_factor = 1.19
|
|
||||||
elif tax_group_id == 2:
|
|
||||||
tax_factor = 1.07
|
|
||||||
else:
|
|
||||||
raise Error("Unknown tax group ID")
|
|
||||||
|
|
||||||
return net_unit_price * tax_factor + 0.01
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>jon</title>
|
|
||||||
<style>
|
|
||||||
html {
|
|
||||||
font-family: Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
nav > ul {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
nav > ul > li {
|
|
||||||
display: inline-block;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
nav > ul > li + li:before {
|
|
||||||
content: ' · ';
|
|
||||||
}
|
|
||||||
.current-page > a {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.current-page > a:after {
|
|
||||||
content: '↓';
|
|
||||||
font-size: 0.8em;
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: absolute;
|
|
||||||
display: block;
|
|
||||||
right: 50%;
|
|
||||||
top: -1em;
|
|
||||||
width: 1em;
|
|
||||||
text-align: center;
|
|
||||||
margin-right: -0.5em;
|
|
||||||
animation: wiggle 0.8s ease-in-out 0s infinite;
|
|
||||||
/* animation-direction: alternate; */
|
|
||||||
}
|
|
||||||
.--align-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.--align-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.--centered {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@keyframes wiggle {
|
|
||||||
0%, 100% { margin-top: 0; }
|
|
||||||
50% { margin-top: -0.5em; }
|
|
||||||
/* 100% { transform: rotate(1turn); } */
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
border-spacing: .5em 0;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
font-size: .8em;
|
|
||||||
}
|
|
||||||
@media print {
|
|
||||||
body {
|
|
||||||
font-size: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.form-input > label {
|
|
||||||
font-size: .8em;
|
|
||||||
}
|
|
||||||
.form-input > input:not([type=radio]),
|
|
||||||
.form-input > select {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>jon</h1>
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li {{ "class=current-page" if request.path == "/" else "" }}><a href="/">Home</a></li>
|
|
||||||
<li {{ "class=current-page" if request.path.startswith("/inventory") else "" }}><a href="/inventory">Inventar</a></li>
|
|
||||||
<li {{ "class=current-page" if request.path.startswith("/entry") else "" }}><a href="/entry">Eintragen</a></li>
|
|
||||||
<li {{ "class=current-page" if request.path.startswith("/location") else "" }}>
|
|
||||||
<a href="/location">
|
|
||||||
{% if "location" not in session %}
|
|
||||||
Raum wählen
|
|
||||||
{% else %}
|
|
||||||
Raum: {{ session.location.location_name }}
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<pre>{{ entry }}</pre>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Neuer Inventareintrag</legend>
|
|
||||||
|
|
||||||
<form method="POST" action="/entry/edit-item-data">
|
|
||||||
<div class="form-input">
|
|
||||||
<label for="item_bought">Kaufdatum</label>
|
|
||||||
<input name="item_bought" id="item_bought" type="date" value="{{ (entry.item_bought or now()).strftime('%Y-%m-%d') }}">
|
|
||||||
</div>
|
|
||||||
<div class="form-input">
|
|
||||||
<label for="item_barcode">Barcode</label>
|
|
||||||
<input name="item_barcode" id="item_barcode" type="text" value="{{ entry.item_barcode }}" placeholder="Barcode">
|
|
||||||
</div>
|
|
||||||
<div class="form-input">
|
|
||||||
<label for="item_name">Artikel</label>
|
|
||||||
<input name="item_name" id="item_name" type="text" value="{{ entry.item_name }}" placeholder="Artikel">
|
|
||||||
</div>
|
|
||||||
<div class="form-input">
|
|
||||||
<label for="item_group">Gruppe</label>
|
|
||||||
<select name="item_group" id="item_group">
|
|
||||||
{% for group in groups %}
|
|
||||||
<option value="{{ group.group_id }}"{% if entry.item_group_id == group.group_id %} selected{% endif %}>{{ group.group_name }} ({{ group.group_id }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-input">
|
|
||||||
<label for="item_net_unit_price">Stückpreis (Netto) in €</label>
|
|
||||||
<input name="item_net_unit_price" id="item_net_unit_price" type="number" step="0.01" value="{{ entry.item_net_unit_price }}" placeholder="Stückpreis (Netto) in €">
|
|
||||||
</div>
|
|
||||||
<div class="form-input">
|
|
||||||
<input name="item_tax_group" id="item_tax_group_1" type="radio" value="1"{% if entry.item_tax_group_id == 1 %} selected{% endif %}>
|
|
||||||
<label for="item_tax_group_1">Volle Umsatzsteuer (19%)</label>
|
|
||||||
|
|
||||||
<input name="item_tax_group" id="item_tax_group_2" type="radio" value="2"{% if entry.item_tax_group_id == 2 %} selected{% endif %}>
|
|
||||||
<label for="item_tax_group_2">Ermäßigte Umsatzsteuer (7%)</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-input">
|
|
||||||
<label for="item_amount">Anzahl</label>
|
|
||||||
<input name="item_amount" id="item_amount" type="number" value="{{ entry.item_amount }}" placeholder="Anzahl">
|
|
||||||
</div>
|
|
||||||
<div class="form-input">
|
|
||||||
<label for="item_group">Raum</label>
|
|
||||||
<select name="item_location" id="item_location">
|
|
||||||
{% for location in locations %}
|
|
||||||
<option value="{{ location.location_id }}"{% if entry.item_location_id == location.location_id or ("item_location" not in entry and (session.location.location_id == location.location_id)) %} selected{% endif %}>{{ location.location_name }} ({{ location.location_id }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button>Weiter zu den Snackeinträgen</button>
|
|
||||||
</form>
|
|
||||||
</fieldset>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<a href="/entry/edit-item-data">Neuer Eintrag</a>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<pre>{{ entry }}</pre>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Neuer Inventareintrag</legend>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">ID</th>
|
|
||||||
<td>{{ entry.item_id }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">Barcode</th>
|
|
||||||
<td><code>{{ entry.item_barcode }}</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">Name</th>
|
|
||||||
<td>{{ entry.item_name }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">Einkaufspreis (Netto)</th>
|
|
||||||
<td>{{ format_currency(entry.item_net_unit_price) }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">Empfohlener Garfield-Verkaufspreis</th>
|
|
||||||
<td>{{ format_currency(get_garfield_price(entry.item_net_unit_price, entry.item_tax_group_id)) }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">Kaufdatum</th>
|
|
||||||
<td>{{ format_date(entry.item_bought) }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">Gruppe</th>
|
|
||||||
<td>{{ entry.item_group_name }} ({{ entry.item_group_id }})</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">Anzahl</th>
|
|
||||||
<td>{{ entry.item_amount }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="--align-left">Raum</th>
|
|
||||||
<td>{{ entry.item_location_name }} ({{ entry.item_location_id }})</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Snackeinträge mit Barcode <code>{{ entry.item_barcode }}</code></legend>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Barcode</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Verkaufspreis (Brutto)</th>
|
|
||||||
<th>Eintragedatum</th>
|
|
||||||
<th>Steuersatz</th>
|
|
||||||
<th>Raum</th>
|
|
||||||
<th>Aktiv?</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
{% for snack in snacks %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ snack.snack_id }}</td>
|
|
||||||
<td><code>{{ snack.snack_barcode }}</code></td>
|
|
||||||
<td>{{ snack.snack_name }}</td>
|
|
||||||
<td class="--align-right">{{ format_currency(snack.snack_price) }}</td>
|
|
||||||
<td>{{ format_date(snack.snack_timestamp) }}</td>
|
|
||||||
<td>{{ snack.description }} ({{ snack.tax_group_id }})</td>
|
|
||||||
<td>{{ snack.location_name }} ({{ snack.location_id }})</td>
|
|
||||||
<td>{{ format_bool(snack.snack_available) }}</td>
|
|
||||||
<td>
|
|
||||||
<form method="POST" action="/entry/select-snack-entry">
|
|
||||||
<input type="hidden" name="snack_id" value="{{ snack.snack_id }}">
|
|
||||||
<button>Snackeintrag übernehmen</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</fieldset>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Barcode</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Preis (Netto)</th>
|
|
||||||
<th>Kaufdatum</th>
|
|
||||||
<th>Gruppe</th>
|
|
||||||
<th>Eingekauft</th>
|
|
||||||
<th title="Korrekturen">Korr.</th>
|
|
||||||
<th>Inventar</th>
|
|
||||||
<th title="Anzahl aktiver Snackeinträge">#AS</th>
|
|
||||||
<th>Raum</th>
|
|
||||||
</tr>
|
|
||||||
{% for item in items %}
|
|
||||||
<tr>
|
|
||||||
<td><a href="/inventory/item/{{ item.item_id }}">{{ item.item_id }}</a></td>
|
|
||||||
<td><code>{{ item.item_barcode }}</code></td>
|
|
||||||
<td>{{ item.name }}</td>
|
|
||||||
<td class="--align-right">{{ format_currency(item.unit_price) }}</td>
|
|
||||||
<td>{{ format_date(item.bought) }}</td>
|
|
||||||
<td>{{ item.group_name }} ({{ item.item_group }})</td>
|
|
||||||
<td class="--align-right">{{ item.sales_units }}</td>
|
|
||||||
<td class="--align-right">{% if item.correction_delta > 0 %}+{% endif %}{{ item.correction_delta }}</td>
|
|
||||||
<td class="--align-right">{{ item.units_left }}</td>
|
|
||||||
<td class="--align-right">{{ item.active_mappings }}</td>
|
|
||||||
<td>{{ item.location_name }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
blinker==1.6.2
|
blinker==1.6.2
|
||||||
click==8.1.3
|
click==8.1.3
|
||||||
Flask==2.3.2
|
Flask==2.3.2
|
||||||
|
Flask-Login==0.6.2
|
||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
MarkupSafe==2.1.2
|
MarkupSafe==2.1.2
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
{-# LANGUAGE FlexibleContexts #-}
|
|
||||||
{-# LANGUAGE OverloadedRecordDot #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
{-# LANGUAGE TypeApplications #-}
|
|
||||||
|
|
||||||
module Jon.Garfield.Queries where
|
|
||||||
|
|
||||||
import Data.Int (Int64)
|
|
||||||
import Data.Scientific (Scientific)
|
|
||||||
import Data.Text (Text)
|
|
||||||
import Database.Beam
|
|
||||||
import Database.Beam.Postgres
|
|
||||||
import Database.PostgreSQL.Simple
|
|
||||||
import Text.Printf (printf)
|
|
||||||
|
|
||||||
import qualified Data.Text as Text
|
|
||||||
|
|
||||||
import Jon.Garfield.Types
|
|
||||||
|
|
||||||
-- Selects
|
|
||||||
|
|
||||||
runSelect
|
|
||||||
:: (FromBackendRow Postgres (QExprToIdentity e), Projectible Postgres e)
|
|
||||||
=> Connection
|
|
||||||
-> Q Postgres db QBaseScope e
|
|
||||||
-> IO [QExprToIdentity e]
|
|
||||||
runSelect conn q = runBeamPostgresDebug putStrLn conn $ runSelectReturningList $ select q
|
|
||||||
|
|
||||||
overviewItems
|
|
||||||
:: Q Postgres GarfieldDb s (OverviewT (QExpr Postgres s), InventoryItemT (QExpr Postgres s))
|
|
||||||
overviewItems = do
|
|
||||||
ov <- all_ garfieldDb.overview
|
|
||||||
it <- related_ garfieldDb.inventoryItems ov.itemId
|
|
||||||
pure (ov, it)
|
|
||||||
|
|
||||||
overviewItemsByLocation
|
|
||||||
:: LocationId
|
|
||||||
-> Q Postgres GarfieldDb s (OverviewT (QExpr Postgres s), InventoryItemT (QExpr Postgres s))
|
|
||||||
overviewItemsByLocation loc = do
|
|
||||||
row@(_, it) <- overviewItems
|
|
||||||
guard_ $ it.location ==. val_ loc
|
|
||||||
pure row
|
|
||||||
|
|
||||||
unsoundBarcodes
|
|
||||||
:: LocationId
|
|
||||||
-> Q Postgres GarfieldDb s (QExpr Postgres s Text, QExpr Postgres s Text, QExpr Postgres s Int64, QExpr Postgres s Int64)
|
|
||||||
unsoundBarcodes loc =
|
|
||||||
filter_ (\(_, _, entries, _) -> entries >=. 2) $
|
|
||||||
aggregate_
|
|
||||||
(\(ov, it) ->
|
|
||||||
( group_ it.barcode
|
|
||||||
, fromMaybe_ "" $ max_ it.name
|
|
||||||
, as_ @Int64 countAll_
|
|
||||||
, as_ @Int64 $ cast_ (sum_ ov.unitsLeft) int
|
|
||||||
))
|
|
||||||
(overviewItemsByLocation loc)
|
|
||||||
|
|
||||||
activeItems
|
|
||||||
:: Text -- barcode
|
|
||||||
-> LocationId
|
|
||||||
-> Q Postgres GarfieldDb s (OverviewT (QExpr Postgres s), InventoryItemT (QExpr Postgres s))
|
|
||||||
activeItems barcode loc = do
|
|
||||||
(ov, it) <- overviewItemsByLocation loc
|
|
||||||
guard_ $ it.barcode ==. val_ barcode
|
|
||||||
pure (ov, it)
|
|
||||||
|
|
||||||
locations
|
|
||||||
:: Q Postgres GarfieldDb s (LocationT (QExpr Postgres s))
|
|
||||||
locations = all_ garfieldDb.locations
|
|
||||||
|
|
||||||
getSnacksByItemId
|
|
||||||
:: InventoryItemId
|
|
||||||
-> Q Postgres GarfieldDb s (SnackT (QExpr Postgres s))
|
|
||||||
getSnacksByItemId item = do
|
|
||||||
im <- all_ garfieldDb.inventoryMap
|
|
||||||
sn <- related_ garfieldDb.snacks im.snackId
|
|
||||||
sa <- oneToOne_ garfieldDb.snacksAvailable (.snackId) sn
|
|
||||||
guard_ $ im.inventoryId ==. val_ item
|
|
||||||
guard_ $ sa.available
|
|
||||||
pure sn
|
|
||||||
|
|
||||||
-- Inserts
|
|
||||||
|
|
||||||
runInserts
|
|
||||||
:: Connection
|
|
||||||
-> [SqlInsert Postgres table]
|
|
||||||
-> IO ()
|
|
||||||
runInserts conn is = runBeamPostgresDebug putStrLn conn $ mapM_ runInsert is
|
|
||||||
|
|
||||||
adjustInventory
|
|
||||||
:: InventoryItemId
|
|
||||||
-> Int64
|
|
||||||
-> Text
|
|
||||||
-> SqlInsert Postgres CorrectionT
|
|
||||||
adjustInventory item amount desc = insert garfieldDb.inventoryCorrections $
|
|
||||||
insertExpressions [Correction (val_ item) default_ (val_ amount) (val_ desc)]
|
|
||||||
|
|
||||||
transfer
|
|
||||||
:: InventoryItemId -- ^ to
|
|
||||||
-> InventoryItemId -- ^ from
|
|
||||||
-> Int64 -- ^ amount to transfer. If negative, acts like 'transfer b a (-amount)'
|
|
||||||
-> SqlInsert Postgres CorrectionT
|
|
||||||
transfer from to amount
|
|
||||||
| amount < 0 = transfer to from (-amount)
|
|
||||||
| otherwise = insert garfieldDb.inventoryCorrections $
|
|
||||||
insertExpressions
|
|
||||||
[ Correction
|
|
||||||
(val_ from)
|
|
||||||
default_
|
|
||||||
(val_ $ -amount)
|
|
||||||
(val_ $ Text.pack $ printf "Umbuchung auf %d" $ to.unInventoryItemId)
|
|
||||||
, Correction
|
|
||||||
(val_ to)
|
|
||||||
default_
|
|
||||||
(val_ amount)
|
|
||||||
(val_ $ Text.pack $ printf "Umbuchung von %d" $ from.unInventoryItemId)
|
|
||||||
]
|
|
||||||
|
|
||||||
-- Updates
|
|
||||||
|
|
||||||
runUpdates :: Connection -> [SqlUpdate Postgres table] -> IO ()
|
|
||||||
runUpdates conn us = runBeamPostgresDebug putStrLn conn $ mapM_ runUpdate us
|
|
||||||
|
|
||||||
disableItem :: InventoryItemId -> SqlUpdate Postgres InventoryItemT
|
|
||||||
disableItem itemId = update (inventoryItems garfieldDb)
|
|
||||||
(\it -> it.available <-. val_ False)
|
|
||||||
(\it -> it.id ==. val_ itemId.unInventoryItemId)
|
|
||||||
|
|
||||||
-- Function calls
|
|
||||||
|
|
||||||
type SqlFunction a = Connection -> IO a
|
|
||||||
|
|
||||||
runFunction :: Connection -> SqlFunction a -> IO a
|
|
||||||
runFunction conn f = f conn
|
|
||||||
|
|
||||||
snackDelete :: SnackId -> SqlFunction ()
|
|
||||||
snackDelete snack conn = do
|
|
||||||
[Only ()] <- query conn "SELECT garfield.snack_delete(?)" (Only $ snack.unSnackId)
|
|
||||||
pure ()
|
|
||||||
|
|
||||||
snackCreate :: Text -> Text -> Scientific -> TaxGroupId -> LocationId -> SqlFunction SnackId
|
|
||||||
snackCreate name barcode price taxGroup location conn = do
|
|
||||||
[Only rawSnackId] <- query conn "SELECT garfield.snack_create(?, ?, ?, ?, ?)"
|
|
||||||
(name, barcode, price, taxGroup.unTaxGroupId, location.unLocationId)
|
|
||||||
pure $ mkSnackId rawSnackId
|
|
||||||
|
|
||||||
snackUpdate
|
|
||||||
:: SnackId
|
|
||||||
-> Text -- Name
|
|
||||||
-> Text -- Barcode
|
|
||||||
-> Scientific
|
|
||||||
-> TaxGroupId
|
|
||||||
-> SqlFunction SnackId
|
|
||||||
snackUpdate snack name barcode price taxGroup conn = do
|
|
||||||
[Only rawSnackId] <- query conn "SELECT garfield.snack_update(?, ?, ?, ?, ?)"
|
|
||||||
(snack.unSnackId, name, barcode, price, taxGroup.unTaxGroupId)
|
|
||||||
pure $ mkSnackId rawSnackId
|
|
||||||
|
|
||||||
inventoryMapSnack :: SnackId -> InventoryItemId -> SqlFunction ()
|
|
||||||
inventoryMapSnack snack item conn = do
|
|
||||||
[Only ()] <- query conn "SELECT garfield.inventory_map_snack(?, ?)" (snack.unSnackId, item.unInventoryItemId)
|
|
||||||
pure ()
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
{-# LANGUAGE DeriveAnyClass #-}
|
|
||||||
{-# LANGUAGE DeriveGeneric #-}
|
|
||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
|
||||||
{-# LANGUAGE FlexibleInstances #-}
|
|
||||||
{-# LANGUAGE OverloadedRecordDot #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
{-# LANGUAGE StandaloneDeriving #-}
|
|
||||||
{-# LANGUAGE TypeFamilies #-}
|
|
||||||
{-# LANGUAGE TypeSynonymInstances #-}
|
|
||||||
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
|
||||||
|
|
||||||
module Jon.Garfield.Types
|
|
||||||
( GarfieldDb(..)
|
|
||||||
, Correction
|
|
||||||
, CorrectionId
|
|
||||||
, CorrectionT(..)
|
|
||||||
, InventoryItem
|
|
||||||
, InventoryItemId
|
|
||||||
, InventoryItemT(..)
|
|
||||||
, InventoryItemGroup
|
|
||||||
, InventoryItemGroupId
|
|
||||||
, InventoryItemGroupT(..)
|
|
||||||
, InventoryMap
|
|
||||||
, InventoryMapId
|
|
||||||
, InventoryMapT(..)
|
|
||||||
, TaxGroup
|
|
||||||
, TaxGroupId
|
|
||||||
, TaxGroupT(..)
|
|
||||||
, Location
|
|
||||||
, LocationId
|
|
||||||
, LocationT(..)
|
|
||||||
, Overview
|
|
||||||
, OverviewId
|
|
||||||
, OverviewT(..)
|
|
||||||
, Snack
|
|
||||||
, SnackId
|
|
||||||
, SnackT(..)
|
|
||||||
, SnackAvailable
|
|
||||||
, SnackAvailableId
|
|
||||||
, SnackAvailableT(..)
|
|
||||||
, Sale
|
|
||||||
, SaleId
|
|
||||||
, SaleT(..)
|
|
||||||
, User
|
|
||||||
, UserId
|
|
||||||
, UserT(..)
|
|
||||||
, garfieldDb
|
|
||||||
, mkOverviewId
|
|
||||||
, mkInventoryItemId
|
|
||||||
, mkTaxGroupId
|
|
||||||
, mkLocationId
|
|
||||||
, mkSaleId
|
|
||||||
, mkSnackId
|
|
||||||
, mkSnackAvailableId
|
|
||||||
, mkUserId
|
|
||||||
, unOverviewId
|
|
||||||
, unInventoryItemId
|
|
||||||
, unInventoryItemGroupId
|
|
||||||
, unTaxGroupId
|
|
||||||
, unLocationId
|
|
||||||
, unSaleId
|
|
||||||
, unSnackId
|
|
||||||
, unSnackAvailableId
|
|
||||||
, unUserId
|
|
||||||
) where
|
|
||||||
|
|
||||||
import Data.Functor.Identity (Identity)
|
|
||||||
import Data.Int (Int32, Int64)
|
|
||||||
import Data.Scientific (Scientific)
|
|
||||||
import Data.Text (Text)
|
|
||||||
import Data.Time (UTCTime)
|
|
||||||
import Database.Beam
|
|
||||||
( Beamable
|
|
||||||
, C
|
|
||||||
, Database
|
|
||||||
, DatabaseSettings
|
|
||||||
, Nullable
|
|
||||||
, PrimaryKey
|
|
||||||
, Table(primaryKey)
|
|
||||||
, TableEntity
|
|
||||||
, TableField
|
|
||||||
, FieldModification
|
|
||||||
, dbModification
|
|
||||||
, defaultDbSettings
|
|
||||||
, fieldNamed
|
|
||||||
, modifyTableFields
|
|
||||||
, setEntityName
|
|
||||||
, tableModification
|
|
||||||
, withDbModification
|
|
||||||
)
|
|
||||||
import Database.Beam.Schema.Tables (setEntitySchema)
|
|
||||||
import GHC.Generics (Generic)
|
|
||||||
|
|
||||||
-- Garfield
|
|
||||||
|
|
||||||
data GarfieldDb f = GarfieldDb
|
|
||||||
-- views
|
|
||||||
{ overview :: f (TableEntity OverviewT)
|
|
||||||
-- tables
|
|
||||||
, inventoryItems :: f (TableEntity InventoryItemT)
|
|
||||||
, inventoryItemGroups :: f (TableEntity InventoryItemGroupT)
|
|
||||||
, taxGroups :: f (TableEntity TaxGroupT)
|
|
||||||
, locations :: f (TableEntity LocationT)
|
|
||||||
, inventoryCorrections :: f (TableEntity CorrectionT)
|
|
||||||
, snacks :: f (TableEntity SnackT)
|
|
||||||
, sales :: f (TableEntity SaleT)
|
|
||||||
, users :: f (TableEntity UserT)
|
|
||||||
, inventoryMap :: f (TableEntity InventoryMapT)
|
|
||||||
, snacksAvailable :: f (TableEntity SnackAvailableT)
|
|
||||||
} deriving (Generic, Database be)
|
|
||||||
|
|
||||||
setGarfieldEntityName name = setEntitySchema (Just "garfield") <> setEntityName name
|
|
||||||
|
|
||||||
type Mod f = f (FieldModification (TableField f))
|
|
||||||
|
|
||||||
garfieldDb :: DatabaseSettings be GarfieldDb
|
|
||||||
garfieldDb = defaultDbSettings `withDbModification`
|
|
||||||
dbModification
|
|
||||||
{ overview = setGarfieldEntityName "inventory_item_overview" <> modifyTableFields tableModification
|
|
||||||
{ itemId = InventoryItemId "item_id"
|
|
||||||
, salesUnits = "sales_units"
|
|
||||||
, unitPrice = "unit_price"
|
|
||||||
, groupName = "group_name"
|
|
||||||
, unitsLeft = "units_left"
|
|
||||||
, correctionDelta = "correction_delta"
|
|
||||||
, activeMappings = "active_mappings"
|
|
||||||
, locationName = "location_name"
|
|
||||||
}
|
|
||||||
, inventoryItems = setGarfieldEntityName "inventory_items" <> modifyTableFields tableModification
|
|
||||||
{ id = "item_id"
|
|
||||||
, group = InventoryItemGroupId "item_group"
|
|
||||||
, bestBefore = "best_before"
|
|
||||||
, barcode = "item_barcode"
|
|
||||||
, unitPrice = "unit_price"
|
|
||||||
, salesUnits = "sales_units"
|
|
||||||
, taxGroup = TaxGroupId "tax_group"
|
|
||||||
, location = LocationId "location"
|
|
||||||
}
|
|
||||||
, inventoryItemGroups = setGarfieldEntityName "inventory_item_groups" <> modifyTableFields (tableModification :: Mod InventoryItemGroupT)
|
|
||||||
{ id = "group_id"
|
|
||||||
, name = "group_name"
|
|
||||||
}
|
|
||||||
, taxGroups = setGarfieldEntityName "tax_groups" <> modifyTableFields (tableModification :: Mod TaxGroupT)
|
|
||||||
{ id = "tax_group_id"
|
|
||||||
}
|
|
||||||
, locations = setGarfieldEntityName "locations" <> modifyTableFields tableModification
|
|
||||||
{ id = "location_id"
|
|
||||||
, name = "location_name"
|
|
||||||
, description = "location_description"
|
|
||||||
}
|
|
||||||
, inventoryCorrections = setGarfieldEntityName "inventory_correction" <> modifyTableFields tableModification
|
|
||||||
{ itemId = InventoryItemId "item_id"
|
|
||||||
, time = "correction_time"
|
|
||||||
, comment = "correction_comment"
|
|
||||||
}
|
|
||||||
, snacks = setGarfieldEntityName "snacks" <> modifyTableFields tableModification
|
|
||||||
{ id = "snack_id"
|
|
||||||
, name = "snack_name"
|
|
||||||
, barcode = "snack_barcode"
|
|
||||||
, price = "snack_price"
|
|
||||||
, location = LocationId "location_id"
|
|
||||||
, timestamp = "snack_timestamp"
|
|
||||||
, taxGroup = TaxGroupId "tax_group_id"
|
|
||||||
}
|
|
||||||
, sales = setGarfieldEntityName "snack_sales_log" <> modifyTableFields tableModification
|
|
||||||
{ id = "snack_sales_log_id"
|
|
||||||
, timestamp = "snack_sales_log_timestamp"
|
|
||||||
, snack = SnackId "snack_id"
|
|
||||||
, location = LocationId "location_id"
|
|
||||||
, type' = "type_id"
|
|
||||||
, grossPrice = "gross_price"
|
|
||||||
, inventoryItem = InventoryItemId "inventory_line"
|
|
||||||
}
|
|
||||||
, users = setGarfieldEntityName "users" <> modifyTableFields tableModification
|
|
||||||
{ id = "user_id"
|
|
||||||
, name = "user_name"
|
|
||||||
, fullName = "user_full_name"
|
|
||||||
, alwaysSendBalanceMail = "always_send_balance_mail"
|
|
||||||
, sendHistoryMail = "send_history_mail"
|
|
||||||
}
|
|
||||||
, inventoryMap = setGarfieldEntityName "inventory_map" <> modifyTableFields tableModification
|
|
||||||
{ inventoryId = InventoryItemId "inventory_id"
|
|
||||||
, snackId = SnackId "snack_id"
|
|
||||||
}
|
|
||||||
, snacksAvailable = setGarfieldEntityName "snacks_available" <> modifyTableFields tableModification
|
|
||||||
{ snackId = SnackId "snack_id"
|
|
||||||
, available = "snack_available"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Views
|
|
||||||
|
|
||||||
data OverviewT f = Overview
|
|
||||||
{ itemId :: PrimaryKey InventoryItemT f
|
|
||||||
, name :: C f Text
|
|
||||||
, salesUnits :: C f Int64
|
|
||||||
, unitPrice :: C f Scientific
|
|
||||||
, sales :: C f Scientific
|
|
||||||
, groupName :: C f Text
|
|
||||||
, unitsLeft :: C f Scientific
|
|
||||||
, correctionDelta :: C f Scientific
|
|
||||||
, activeMappings :: C f Scientific
|
|
||||||
, locationName :: C f Text
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type Overview = OverviewT Identity
|
|
||||||
type OverviewId = PrimaryKey OverviewT Identity
|
|
||||||
|
|
||||||
mkOverviewId :: Int32 -> OverviewId
|
|
||||||
mkOverviewId = OverviewId
|
|
||||||
|
|
||||||
deriving instance Show Overview
|
|
||||||
deriving instance Show OverviewId
|
|
||||||
|
|
||||||
instance Table OverviewT where
|
|
||||||
data PrimaryKey OverviewT f
|
|
||||||
= OverviewId { unOverviewId :: C f Int32 } deriving (Beamable, Generic)
|
|
||||||
primaryKey = OverviewId . unInventoryItemId . (.itemId)
|
|
||||||
|
|
||||||
-- Tables
|
|
||||||
|
|
||||||
data InventoryItemT f = InventoryItem
|
|
||||||
{ id :: C f Int32
|
|
||||||
, available :: C f Bool
|
|
||||||
, name :: C f Text
|
|
||||||
, group :: PrimaryKey InventoryItemGroupT f
|
|
||||||
, bought :: C f UTCTime
|
|
||||||
, bestBefore :: C f UTCTime
|
|
||||||
, barcode :: C f Text
|
|
||||||
, unitPrice :: C f Scientific
|
|
||||||
, salesUnits :: C f Int64
|
|
||||||
, taxGroup :: PrimaryKey TaxGroupT f
|
|
||||||
, location :: PrimaryKey LocationT f
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type InventoryItem = InventoryItemT Identity
|
|
||||||
type InventoryItemId = PrimaryKey InventoryItemT Identity
|
|
||||||
|
|
||||||
mkInventoryItemId :: Int32 -> InventoryItemId
|
|
||||||
mkInventoryItemId = InventoryItemId
|
|
||||||
|
|
||||||
deriving instance Show InventoryItem
|
|
||||||
deriving instance Show InventoryItemId
|
|
||||||
deriving instance Show (PrimaryKey InventoryItemT (Nullable Identity))
|
|
||||||
|
|
||||||
instance Table InventoryItemT where
|
|
||||||
data PrimaryKey InventoryItemT f
|
|
||||||
= InventoryItemId { unInventoryItemId :: C f Int32 } deriving (Beamable, Generic)
|
|
||||||
primaryKey = InventoryItemId . (.id)
|
|
||||||
|
|
||||||
|
|
||||||
data InventoryItemGroupT f = InventoryItemGroup
|
|
||||||
{ id :: C f Int32
|
|
||||||
, name :: C f Text
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type InventoryItemGroup = InventoryItemGroupT Identity
|
|
||||||
type InventoryItemGroupId = PrimaryKey InventoryItemGroupT Identity
|
|
||||||
|
|
||||||
deriving instance Show InventoryItemGroup
|
|
||||||
deriving instance Show InventoryItemGroupId
|
|
||||||
|
|
||||||
instance Table InventoryItemGroupT where
|
|
||||||
data PrimaryKey InventoryItemGroupT f
|
|
||||||
= InventoryItemGroupId { unInventoryItemGroupId :: C f Int32 } deriving (Beamable, Generic)
|
|
||||||
primaryKey = InventoryItemGroupId . (.id)
|
|
||||||
|
|
||||||
|
|
||||||
data TaxGroupT f = TaxGroup
|
|
||||||
{ id :: C f Int32
|
|
||||||
, description :: C f Text
|
|
||||||
, active :: C f Bool
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type TaxGroup = TaxGroupT Identity
|
|
||||||
type TaxGroupId = PrimaryKey TaxGroupT Identity
|
|
||||||
|
|
||||||
mkTaxGroupId = TaxGroupId
|
|
||||||
|
|
||||||
deriving instance Show TaxGroup
|
|
||||||
deriving instance Show TaxGroupId
|
|
||||||
|
|
||||||
instance Table TaxGroupT where
|
|
||||||
data PrimaryKey TaxGroupT f
|
|
||||||
= TaxGroupId { unTaxGroupId :: C f Int32 } deriving (Beamable, Generic)
|
|
||||||
primaryKey = TaxGroupId . (.id)
|
|
||||||
|
|
||||||
|
|
||||||
data LocationT f = Location
|
|
||||||
{ id :: C f Int32
|
|
||||||
, name :: C f Text
|
|
||||||
, description :: C f Text
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type Location = LocationT Identity
|
|
||||||
type LocationId = PrimaryKey LocationT Identity
|
|
||||||
|
|
||||||
mkLocationId :: Int32 -> LocationId
|
|
||||||
mkLocationId = LocationId
|
|
||||||
|
|
||||||
deriving instance Show Location
|
|
||||||
deriving instance Show LocationId
|
|
||||||
|
|
||||||
instance Table LocationT where
|
|
||||||
data PrimaryKey LocationT f
|
|
||||||
= LocationId { unLocationId :: C f Int32 } deriving (Beamable, Generic)
|
|
||||||
primaryKey = LocationId . (.id)
|
|
||||||
|
|
||||||
|
|
||||||
data CorrectionT f = Correction
|
|
||||||
{ itemId :: PrimaryKey InventoryItemT f
|
|
||||||
, time :: C f UTCTime
|
|
||||||
, delta :: C f Int64
|
|
||||||
, comment :: C f Text
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type Correction = CorrectionT Identity
|
|
||||||
type CorrectionId = PrimaryKey CorrectionT Identity
|
|
||||||
|
|
||||||
deriving instance Show Correction
|
|
||||||
deriving instance Show CorrectionId
|
|
||||||
|
|
||||||
instance Table CorrectionT where
|
|
||||||
data PrimaryKey CorrectionT f
|
|
||||||
= CorrectionId deriving (Beamable, Generic)
|
|
||||||
primaryKey _ = CorrectionId
|
|
||||||
|
|
||||||
|
|
||||||
data SnackT f = Snack
|
|
||||||
{ id :: C f Int32
|
|
||||||
, name :: C f Text
|
|
||||||
, barcode :: C (Nullable f) Text
|
|
||||||
, price :: C f Scientific
|
|
||||||
, location :: PrimaryKey LocationT f
|
|
||||||
-- , snackModifiedBy :: PrimaryKey UserT f
|
|
||||||
, timestamp :: C (Nullable f) UTCTime
|
|
||||||
, taxGroup :: PrimaryKey TaxGroupT f
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type Snack = SnackT Identity
|
|
||||||
type SnackId = PrimaryKey SnackT Identity
|
|
||||||
|
|
||||||
mkSnackId :: Int32 -> SnackId
|
|
||||||
mkSnackId = SnackId
|
|
||||||
|
|
||||||
deriving instance Show Snack
|
|
||||||
deriving instance Show SnackId
|
|
||||||
|
|
||||||
instance Table SnackT where
|
|
||||||
data PrimaryKey SnackT f
|
|
||||||
= SnackId { unSnackId :: C f Int32 } deriving (Beamable, Generic)
|
|
||||||
primaryKey = SnackId . (.id)
|
|
||||||
|
|
||||||
|
|
||||||
data SaleT f = Sale
|
|
||||||
{ id :: C f Int32
|
|
||||||
, timestamp :: C f UTCTime
|
|
||||||
, snack :: PrimaryKey SnackT f
|
|
||||||
, location :: PrimaryKey LocationT f
|
|
||||||
, type' :: C f Text
|
|
||||||
-- , saleSubtotal :: PrimaryKey SubtotalT f
|
|
||||||
, grossPrice :: C f Scientific
|
|
||||||
-- , saleTax :: PrimaryKey TaxT f
|
|
||||||
, inventoryItem :: PrimaryKey InventoryItemT (Nullable f)
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type Sale = SaleT Identity
|
|
||||||
type SaleId = PrimaryKey SaleT Identity
|
|
||||||
|
|
||||||
mkSaleId :: Int32 -> SaleId
|
|
||||||
mkSaleId = SaleId
|
|
||||||
|
|
||||||
deriving instance Show Sale
|
|
||||||
deriving instance Show SaleId
|
|
||||||
|
|
||||||
instance Table SaleT where
|
|
||||||
data PrimaryKey SaleT f
|
|
||||||
= SaleId { unSaleId :: C f Int32 } deriving (Beamable, Generic)
|
|
||||||
primaryKey = SaleId . (.id)
|
|
||||||
|
|
||||||
|
|
||||||
data UserT f = User
|
|
||||||
{ id :: C f Int32
|
|
||||||
, name :: C f Text
|
|
||||||
, fullName :: C f Text
|
|
||||||
, alwaysSendBalanceMail :: C f Bool
|
|
||||||
, sendHistoryMail :: C f Bool
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type User = UserT Identity
|
|
||||||
type UserId = PrimaryKey UserT Identity
|
|
||||||
|
|
||||||
mkUserId :: Int32 -> UserId
|
|
||||||
mkUserId = UserId
|
|
||||||
|
|
||||||
deriving instance Show User
|
|
||||||
deriving instance Show UserId
|
|
||||||
|
|
||||||
instance Table UserT where
|
|
||||||
data PrimaryKey UserT f
|
|
||||||
= UserId { unUserId :: C f Int32 } deriving (Beamable, Generic)
|
|
||||||
primaryKey = UserId . (.id)
|
|
||||||
|
|
||||||
|
|
||||||
data InventoryMapT f = InventoryMap
|
|
||||||
{ inventoryId :: PrimaryKey InventoryItemT f
|
|
||||||
, snackId :: PrimaryKey SnackT f
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type InventoryMap = InventoryMapT Identity
|
|
||||||
type InventoryMapId = PrimaryKey InventoryMapT Identity
|
|
||||||
|
|
||||||
deriving instance Show InventoryMap
|
|
||||||
deriving instance Show InventoryMapId
|
|
||||||
|
|
||||||
instance Table InventoryMapT where
|
|
||||||
data PrimaryKey InventoryMapT f
|
|
||||||
= InventoryMapId deriving (Beamable, Generic)
|
|
||||||
primaryKey _ = InventoryMapId
|
|
||||||
|
|
||||||
|
|
||||||
data SnackAvailableT f = SnackAvailable
|
|
||||||
{ snackId :: PrimaryKey SnackT f
|
|
||||||
, available :: C f Bool
|
|
||||||
} deriving (Beamable, Generic)
|
|
||||||
|
|
||||||
type SnackAvailable = SnackAvailableT Identity
|
|
||||||
type SnackAvailableId = PrimaryKey SnackAvailableT Identity
|
|
||||||
|
|
||||||
mkSnackAvailableId :: SnackId -> SnackAvailableId
|
|
||||||
mkSnackAvailableId = SnackAvailableId
|
|
||||||
|
|
||||||
deriving instance Show SnackAvailable
|
|
||||||
deriving instance Show SnackAvailableId
|
|
||||||
|
|
||||||
instance Table SnackAvailableT where
|
|
||||||
data PrimaryKey SnackAvailableT f
|
|
||||||
= SnackAvailableId { unSnackAvailableId :: PrimaryKey SnackT f } deriving (Beamable, Generic)
|
|
||||||
primaryKey = SnackAvailableId . (.snackId)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{-# LANGUAGE DataKinds #-}
|
|
||||||
{-# LANGUAGE FlexibleContexts #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
{-# LANGUAGE TypeOperators #-}
|
|
||||||
|
|
||||||
module Jon.Main
|
|
||||||
( main
|
|
||||||
) where
|
|
||||||
|
|
||||||
import Control.Exception (bracket)
|
|
||||||
import Database.Beam.Postgres
|
|
||||||
import Servant
|
|
||||||
import Servant.Swagger.UI
|
|
||||||
import System.Environment
|
|
||||||
import Network.Wai.Handler.Warp (run)
|
|
||||||
|
|
||||||
import qualified Data.ByteString.Char8 as BS8
|
|
||||||
|
|
||||||
import Jon.Server (JonAPI, jonSwaggerDoc, server)
|
|
||||||
|
|
||||||
main :: IO ()
|
|
||||||
main = withGarfieldConn $ \conn ->
|
|
||||||
run 8080 $ serve p (server conn :<|> swaggerSchemaUIServer jonSwaggerDoc :<|> serveDirectoryFileServer "./static")
|
|
||||||
where
|
|
||||||
p :: Proxy (JonAPI :<|> SwaggerSchemaUI "swagger" "swagger.json" :<|> Raw)
|
|
||||||
p = Proxy
|
|
||||||
|
|
||||||
withGarfieldConn :: (Connection -> IO a) -> IO a
|
|
||||||
withGarfieldConn = bracket
|
|
||||||
(do pass <- getEnv "JON_PASS"
|
|
||||||
connectPostgreSQL $ BS8.pack $ "host=localhost dbname=garfield password=" ++ pass)
|
|
||||||
close
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
{-# LANGUAGE DataKinds #-}
|
|
||||||
{-# LANGUAGE DeriveAnyClass #-}
|
|
||||||
{-# LANGUAGE DeriveGeneric #-}
|
|
||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
|
||||||
{-# LANGUAGE FlexibleContexts #-}
|
|
||||||
{-# LANGUAGE FlexibleInstances #-}
|
|
||||||
{-# LANGUAGE OverloadedLists #-}
|
|
||||||
{-# LANGUAGE OverloadedRecordDot #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
{-# LANGUAGE StandaloneDeriving #-}
|
|
||||||
{-# LANGUAGE TypeOperators #-}
|
|
||||||
{-# LANGUAGE TypeSynonymInstances #-}
|
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
|
||||||
|
|
||||||
module Jon.Server (JonAPI, jonSwaggerDoc, server) where
|
|
||||||
|
|
||||||
import Control.Lens
|
|
||||||
import Control.Monad.IO.Class (liftIO)
|
|
||||||
import Data.Aeson (FromJSON(parseJSON), ToJSON(toJSON))
|
|
||||||
import Data.Text (Text)
|
|
||||||
import Data.Int (Int32, Int64)
|
|
||||||
import Data.Scientific (Scientific)
|
|
||||||
import Data.Swagger
|
|
||||||
import Database.PostgreSQL.Simple (Connection)
|
|
||||||
import GHC.Generics (Generic)
|
|
||||||
import Servant
|
|
||||||
import Servant.Swagger (toSwagger)
|
|
||||||
|
|
||||||
import Jon.Garfield.Types
|
|
||||||
|
|
||||||
import qualified Jon.Garfield.Queries as Queries
|
|
||||||
|
|
||||||
-- API and types
|
|
||||||
|
|
||||||
type JonAPI =
|
|
||||||
"rpc" :>
|
|
||||||
( "getUnsoundBarcodes" :> Summary "Get information on barcodes with at least two items"
|
|
||||||
:> ReqBody '[JSON] GetUnsoundBarcodesP
|
|
||||||
:> Post '[JSON] [UnsoundBarcodeDTO]
|
|
||||||
:<|> "getOverviewItems" :> Summary "Get overview of all active items"
|
|
||||||
:> ReqBody '[JSON] GetOverviewItemsP
|
|
||||||
:> Post '[JSON] [OverviewItemDTO]
|
|
||||||
:<|> "getActiveItems" :> Summary "Get currently active items for a barcode"
|
|
||||||
:> ReqBody '[JSON] GetActiveItemsP
|
|
||||||
:> Post '[JSON] [OverviewItemDTO]
|
|
||||||
:<|> "getLocations" :> Summary "Get a list of all locations"
|
|
||||||
:> Post '[JSON] [Location]
|
|
||||||
:<|> "adjustInventory" :> ReqBody '[JSON] AdjustInventoryP
|
|
||||||
:> PostNoContent
|
|
||||||
:<|> "transferInventory" :> Summary "Transfer inventory between items"
|
|
||||||
:> Description "If `amount` is negative, its absolute value is transferred in the opposite direction."
|
|
||||||
:> ReqBody '[JSON] TransferInventoryP
|
|
||||||
:> PostNoContent
|
|
||||||
:<|> "disableItems" :> Summary "Disable inventory items"
|
|
||||||
:> ReqBody '[JSON] DisableItemsP
|
|
||||||
:> PostNoContent
|
|
||||||
:<|> SnackAPI
|
|
||||||
)
|
|
||||||
|
|
||||||
type SnackAPI =
|
|
||||||
"createSnack" :> Summary "Create a snack"
|
|
||||||
:> ReqBody '[JSON] CreateSnackP
|
|
||||||
:> Post '[JSON] SnackId
|
|
||||||
:<|> "getSnacksByItemId" :> Summary "Get active snacks by item id"
|
|
||||||
:> ReqBody '[JSON] GetSnacksByItemIdP
|
|
||||||
:> Post '[JSON] [Snack]
|
|
||||||
:<|> "updateSnack" :> Summary "Update a snack"
|
|
||||||
:> ReqBody '[JSON] UpdateSnackP
|
|
||||||
:> Post '[JSON] SnackId
|
|
||||||
:<|> "deleteSnack" :> Summary "Delete a snack"
|
|
||||||
:> ReqBody '[JSON] DeleteSnackP
|
|
||||||
:> PostNoContent
|
|
||||||
|
|
||||||
data GetUnsoundBarcodesP = GetUnsoundBarcodesP
|
|
||||||
{ location :: LocationId
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data UnsoundBarcodeDTO = UnsoundBarcodeDTO
|
|
||||||
{ barcode :: Text
|
|
||||||
, name :: Text
|
|
||||||
, entries :: Int
|
|
||||||
, unitsLeft :: Int
|
|
||||||
} deriving (Generic, ToJSON, ToSchema)
|
|
||||||
|
|
||||||
data GetOverviewItemsP = GetOverviewItemsP
|
|
||||||
{ location :: LocationId
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data OverviewItemDTO = OverviewItemDTO
|
|
||||||
{ overview :: Overview
|
|
||||||
, item :: InventoryItem
|
|
||||||
} deriving (Generic, ToJSON, ToSchema)
|
|
||||||
|
|
||||||
mkOverviewItemDTO = uncurry OverviewItemDTO
|
|
||||||
|
|
||||||
data GetActiveItemsP = GetActiveItemsP
|
|
||||||
{ barcode :: Text
|
|
||||||
, location :: LocationId
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data AdjustInventoryP = AdjustInventoryP
|
|
||||||
{ item :: InventoryItemId
|
|
||||||
, amount :: Int64
|
|
||||||
, description :: Text
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data TransferInventoryP = TransferInventoryP
|
|
||||||
{ transfers :: [InventoryTransferDTO]
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data InventoryTransferDTO = InventoryTransfer
|
|
||||||
{ from :: InventoryItemId
|
|
||||||
, to :: InventoryItemId
|
|
||||||
, amount :: Int64
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data DisableItemsP = DisableItemsP
|
|
||||||
{ items :: [InventoryItemId]
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data CreateSnackP = CreateSnackP
|
|
||||||
{ name :: Text
|
|
||||||
, barcode :: Text
|
|
||||||
, price :: Scientific
|
|
||||||
, taxGroup :: TaxGroupId
|
|
||||||
, location :: LocationId
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data GetSnacksByItemIdP = GetSnacksByItemIdP
|
|
||||||
{ item :: InventoryItemId
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data UpdateSnackP = UpdateSnackP
|
|
||||||
{ snack :: SnackId
|
|
||||||
, name :: Text
|
|
||||||
, barcode :: Text
|
|
||||||
, price :: Scientific
|
|
||||||
, taxGroup :: TaxGroupId
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
data DeleteSnackP = DeleteSnackP
|
|
||||||
{ snack :: SnackId
|
|
||||||
} deriving (Generic, FromJSON, ToSchema)
|
|
||||||
|
|
||||||
-- Orphan instances for database types
|
|
||||||
-- needed for serialization and swagger doc
|
|
||||||
|
|
||||||
instance ToJSON InventoryItemId where
|
|
||||||
toJSON = toJSON . (.unInventoryItemId)
|
|
||||||
instance FromJSON InventoryItemId where
|
|
||||||
parseJSON = fmap mkInventoryItemId . parseJSON
|
|
||||||
instance ToSchema InventoryItemId where
|
|
||||||
declareNamedSchema _ = declareNamedSchema (Proxy :: Proxy Int32)
|
|
||||||
|
|
||||||
instance ToJSON InventoryItemGroupId where
|
|
||||||
toJSON = toJSON . (.unInventoryItemGroupId)
|
|
||||||
instance ToSchema InventoryItemGroupId where
|
|
||||||
declareNamedSchema _ = declareNamedSchema (Proxy :: Proxy Int32)
|
|
||||||
|
|
||||||
instance ToJSON TaxGroupId where
|
|
||||||
toJSON = toJSON . (.unTaxGroupId)
|
|
||||||
instance FromJSON TaxGroupId where
|
|
||||||
parseJSON = fmap mkTaxGroupId . parseJSON
|
|
||||||
instance ToSchema TaxGroupId where
|
|
||||||
declareNamedSchema _ = declareNamedSchema (Proxy :: Proxy Int32)
|
|
||||||
|
|
||||||
instance ToJSON LocationId where
|
|
||||||
toJSON = toJSON . (.unLocationId)
|
|
||||||
instance FromJSON LocationId where
|
|
||||||
parseJSON = fmap mkLocationId . parseJSON
|
|
||||||
instance ToSchema LocationId where
|
|
||||||
declareNamedSchema _ = declareNamedSchema (Proxy :: Proxy Int32)
|
|
||||||
|
|
||||||
instance ToJSON SnackId where
|
|
||||||
toJSON = toJSON . (.unSnackId)
|
|
||||||
instance FromJSON SnackId where
|
|
||||||
parseJSON = fmap mkSnackId . parseJSON
|
|
||||||
instance ToSchema SnackId where
|
|
||||||
declareNamedSchema _ = declareNamedSchema (Proxy :: Proxy Int32)
|
|
||||||
|
|
||||||
deriving instance ToJSON Overview
|
|
||||||
deriving instance ToSchema Overview
|
|
||||||
|
|
||||||
deriving instance ToJSON InventoryItem
|
|
||||||
deriving instance ToSchema InventoryItem
|
|
||||||
|
|
||||||
deriving instance ToJSON Location
|
|
||||||
deriving instance ToSchema Location
|
|
||||||
|
|
||||||
deriving instance ToJSON Snack
|
|
||||||
deriving instance ToSchema Snack
|
|
||||||
|
|
||||||
-- server
|
|
||||||
|
|
||||||
server :: Connection -> Server JonAPI
|
|
||||||
server conn =
|
|
||||||
getUnsoundBarcodes
|
|
||||||
:<|> getOverviewItems
|
|
||||||
:<|> getActiveItems
|
|
||||||
:<|> getLocations
|
|
||||||
:<|> adjustInventory
|
|
||||||
:<|> transferInventory
|
|
||||||
:<|> disableItems
|
|
||||||
:<|> createSnack
|
|
||||||
:<|> getSnacksByItemId
|
|
||||||
:<|> updateSnack
|
|
||||||
:<|> deleteSnack
|
|
||||||
where
|
|
||||||
getUnsoundBarcodes :: GetUnsoundBarcodesP -> Handler [UnsoundBarcodeDTO]
|
|
||||||
getUnsoundBarcodes params = do
|
|
||||||
rows <- liftIO $ Queries.runSelect conn $ Queries.unsoundBarcodes params.location
|
|
||||||
pure $ map mkUnsoundBarcodeDTO rows
|
|
||||||
where
|
|
||||||
mkUnsoundBarcodeDTO (a, b, c, d) = UnsoundBarcodeDTO a b (fromIntegral c) (fromIntegral d)
|
|
||||||
|
|
||||||
getOverviewItems :: GetOverviewItemsP -> Handler [OverviewItemDTO]
|
|
||||||
getOverviewItems params = do
|
|
||||||
rows <- liftIO $ Queries.runSelect conn $ Queries.overviewItemsByLocation params.location
|
|
||||||
pure $ map mkOverviewItemDTO rows
|
|
||||||
|
|
||||||
getActiveItems :: GetActiveItemsP -> Handler [OverviewItemDTO]
|
|
||||||
getActiveItems params = do
|
|
||||||
rows <- liftIO $ Queries.runSelect conn $ Queries.activeItems params.barcode params.location
|
|
||||||
pure $ map mkOverviewItemDTO rows
|
|
||||||
|
|
||||||
getLocations :: Handler [Location]
|
|
||||||
getLocations = do
|
|
||||||
liftIO $ Queries.runSelect conn Queries.locations
|
|
||||||
|
|
||||||
adjustInventory params = do
|
|
||||||
liftIO $ Queries.runInserts conn [Queries.adjustInventory params.item params.amount params.description]
|
|
||||||
pure NoContent
|
|
||||||
|
|
||||||
transferInventory :: TransferInventoryP -> Handler NoContent
|
|
||||||
transferInventory params = do
|
|
||||||
liftIO $ Queries.runInserts conn $ map (\t -> Queries.transfer t.from t.to t.amount) params.transfers
|
|
||||||
pure NoContent
|
|
||||||
|
|
||||||
disableItems :: DisableItemsP -> Handler NoContent
|
|
||||||
disableItems params = do
|
|
||||||
liftIO $ Queries.runUpdates conn $ map Queries.disableItem params.items
|
|
||||||
pure NoContent
|
|
||||||
|
|
||||||
createSnack params = do
|
|
||||||
liftIO $ Queries.runFunction conn $ Queries.snackCreate
|
|
||||||
params.name
|
|
||||||
params.barcode
|
|
||||||
params.price
|
|
||||||
params.taxGroup
|
|
||||||
params.location
|
|
||||||
|
|
||||||
getSnacksByItemId params = do
|
|
||||||
liftIO $ Queries.runSelect conn $ Queries.getSnacksByItemId params.item
|
|
||||||
|
|
||||||
updateSnack params = do
|
|
||||||
liftIO $ Queries.runFunction conn $ Queries.snackUpdate
|
|
||||||
params.snack
|
|
||||||
params.name
|
|
||||||
params.barcode
|
|
||||||
params.price
|
|
||||||
params.taxGroup
|
|
||||||
|
|
||||||
deleteSnack params = do
|
|
||||||
liftIO $ Queries.runFunction conn $ Queries.snackDelete params.snack
|
|
||||||
pure NoContent
|
|
||||||
|
|
||||||
jonSwaggerDoc :: Swagger
|
|
||||||
jonSwaggerDoc = toSwagger (Proxy :: Proxy JonAPI)
|
|
||||||
& info . title .~ "jon API"
|
|
||||||
& info . version .~ "0.1.1"
|
|
||||||
& applyTags [Tag "jon RPC" (Just "Crudely improvised Garfield API") Nothing]
|
|
||||||
-- Doesn't work :(
|
|
||||||
-- & applyTagsFor (operationsOf $ toSwagger (Proxy :: Proxy SnackAPI)) [Tag "Snacks" Nothing Nothing]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{}:
|
|
||||||
let
|
|
||||||
pkgs = import ./nix/pkgs.nix {};
|
|
||||||
ghc = import ./nix/ghc924.nix pkgs;
|
|
||||||
haskellDeps = import ./nix/haskell-deps.nix;
|
|
||||||
in
|
|
||||||
pkgs.haskell.lib.buildStackProject {
|
|
||||||
name = "jon";
|
|
||||||
ghc = ghc.ghcWithPackages haskellDeps;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
packages:
|
|
||||||
- .
|
|
||||||
resolver: ghc-9.2.4
|
|
||||||
system-ghc: true
|
|
||||||
nix:
|
|
||||||
enable: true
|
|
||||||
shell-file: stack-shell.nix
|
|
||||||
path: ["nixpkgs=./nix/pkgs.nix"]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link rel="stylesheet" href="./jon.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Compiled by Elm -->
|
|
||||||
<script src="./jon.js"></script>
|
|
||||||
<script>
|
|
||||||
Elm.Main.init();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
html {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button, input {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
padding: .2em .4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:not(:first-child):hover, tbody tr:hover {
|
|
||||||
background-color: lightblue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.noprint { display: none; }
|
|
||||||
|
|
||||||
body > div + div {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
main :: IO ()
|
|
||||||
main = putStrLn "Test suite not yet implemented"
|
|
||||||
Reference in New Issue
Block a user