Author SHA1 Message Date
paul ab53b31ec0 Add todo 2024-05-13 19:31:50 +02:00
paul a201ce00b6 Add temporary table with index for speeding up consumption graph query 2024-05-13 19:30:12 +02:00
paul 630a0435a2 Add inline SVG consumption graphs 2023-12-13 13:44:28 +01:00
paul 61cf7696a5 Merge branch 'feature/audit-icons' 2023-12-13 13:38:56 +01:00
paul 3d9ad49020 Add icons to inventory page that indicate common issues 2023-12-05 17:18:39 +01:00
paul 1b6d0c40f3 Merge branch 'feature/auth-token-flask-login' 2023-12-05 15:52:01 +01:00
paul 235ef32efd Add Arch Linux dependencies to readme 2023-12-05 15:50:27 +01:00
paul 85598adaab Use flask-login instead of client-side sessions for storing selected location and orders 2023-08-22 18:31:42 +02:00
paul 70934a2f85 Add flask-login 2023-08-22 18:31:42 +02:00
Lukas Brocke 5cec28cad1 Merge pull request 'Item and Snack Entry' (#4) from feature/entry into main
Reviewed-on: https://git.fsmi.org/paul/jon/pulls/4
2023-08-22 18:24:16 +02:00
paul d57e66c033 Add some types and doc to frontend code 2023-08-22 16:56:04 +02:00
paul d9114d21c7 Some doc stuff 2023-08-22 13:14:56 +02:00
paul 2108ef2418 Add unitsLeft field to SearchResult type 2023-08-22 13:14:56 +02:00
paul a2d1d42362 Implement adding new inventory items 2023-08-22 13:14:56 +02:00
paul 32bcb1b18a fixup! Store location name, group name and tax group description in order 2023-08-22 13:14:56 +02:00
paul 1e4aadee06 Store location name, group name and tax group description in order
Also delete compiled entry JS and add a Makefile
2023-08-22 13:14:56 +02:00
paul a82e75999b Restyle calculator 2023-08-22 13:14:56 +02:00
paul 7b95b69c7b Make it possible to delete orders from the cart 2023-08-22 13:14:56 +02:00
paul e0aef726bd Implement adding orders to the cart and viewing it 2023-08-22 13:14:56 +02:00
paul 7707a9da6d Make entry for submittable 2023-08-22 13:14:56 +02:00
paul ca2e2e2e4a Refactor calculator into its own module 2023-08-22 13:14:56 +02:00
paul 208cc54e53 Implement price calculator 2023-08-22 13:14:56 +02:00
paul f7dd572fe1 Add note about entry.js to readme 2023-08-22 13:14:56 +02:00
paul 69dba202d0 Move around gross unit price input 2023-08-22 13:14:56 +02:00
paul 3d6bd7bac5 Rewrite some of the grossUnitPrice stuff 2023-08-22 13:14:56 +02:00
paul 0f9be13e31 Refactor fronted somewhat 2023-08-22 13:14:56 +02:00
paul be86248cce First draft of elm frontend
This will probably be scrapped or rewritten
2023-08-22 13:14:54 +02:00
paul 01d060cf5f Remove old entry stuff 2023-08-22 13:14:15 +02:00
paul 8c5b34073d Add note about unsafe client sessions 2023-08-21 16:05:28 +02:00
paul fe81014794 Print link with access token to stderr 2023-08-21 14:14:47 +02:00
paul f308bca1fb Use secrets instead of random to generate access token 2023-08-21 13:44:26 +02:00
Julian Keck c6b518bd15 Merge pull request 'Token Auth' (#2) from feature/auth-token into main
Reviewed-on: https://git.fsmi.org/paul/jon/pulls/2
2023-08-20 14:11:41 +02:00
paul abaea8673a Add template for unauthenticated response 2023-08-20 14:10:44 +02:00
paul 4f83ee3a40 Add a comment and code style stuff 2023-08-20 14:10:44 +02:00
paul c453e923ae Use session to store authentication info instead of cookie 2023-08-20 14:10:44 +02:00
Shirkanesi bc9336ef21 Initial work on auth-tokens 2023-08-20 14:10:44 +02:00
Shirkanesi 541029a02d Add common names for venv to gitignore 2023-08-20 14:10:41 +02:00
paul 04f832ac00 Add elm-stuff to .gitignore 2023-08-20 13:45:10 +02:00
paul 5551b6438a Fix some report stuff 2023-08-18 11:29:04 +02:00
paul 1a34486a84 Update report
Report now has a 'total' column next to the 'inventory' column.

Also, for items that are not empty yet we use the current time instead of the most recent sale to estimate time until empty.
2023-08-18 00:14:11 +02:00
paul 53eb6a9fba Fix ssh host in README 2023-08-18 00:12:42 +02:00
paul 243d91abb3 Add Python version note 2023-08-13 00:04:43 +02:00
paul f666075f3e Add some notes about setup and running jon 2023-08-11 14:53:04 +02:00
paul 2045496ef4 Move TODO to README 2023-08-11 14:39:49 +02:00
paul fd02d162aa Move Python code into repo root 2023-08-11 14:37:51 +02:00
paul b729e91aaa Remove Haskell code 2023-08-11 14:35:55 +02:00
paul 3ad649a402 Add /inventory/report route 2023-08-11 14:34:57 +02:00
paul 7c1e13e32e Fix read_item view 2023-07-17 17:15:10 +02:00
paul 37179e65a1 Merge branch 'feature/entry' 2023-07-03 18:22:41 +02:00
BuildTools 73bebf0aec Add configuration files 2023-07-03 18:22:14 +02:00
BuildTools 8fda78c466 Add transfer button 2023-07-03 18:22:14 +02:00
BuildTools 38c827d0d8 Add configuration files 2023-06-30 17:37:09 +02:00
BuildTools 0f3416b49a Add transfer button 2023-06-30 17:20:15 +02:00
73 changed files with 1481 additions and 2259 deletions
+5 -4
View File
@@ -1,7 +1,8 @@
.stack-work/
.vscode/
.setjonpass
elm-stuff
static/jon.js
__pycache__
*.swp
jon/config.json
elm-stuff/
venv/
.venv/
jon/static/*.js
-7
View File
@@ -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.
+6 -2
View File
@@ -1,2 +1,6 @@
static/jon.js: elm/*.elm $(wildcard elm/**/*.elm)
elm make --debug --output static/jon.js elm/Main.elm
.PHONY: frontend
frontend: jon/static/entry.js
jon/static/entry.js: $(shell find frontend -name '*.elm')
elm make --optimize frontend/Entry.elm --output $@
+64 -1
View File
@@ -1,8 +1,71 @@
# jon
> 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.
## 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
-2
View File
@@ -1,2 +0,0 @@
import Distribution.Simple
main = defaultMain
-3
View File
@@ -1,3 +0,0 @@
module Main (main) where
import Jon.Main (main)
-21
View File
@@ -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;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "application",
"source-directories": [
"elm"
"frontend"
],
"elm-version": "0.19.1",
"dependencies": {
-425
View File
@@ -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
+128
View File
@@ -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
+405
View File
@@ -0,0 +1,405 @@
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
, groups : List Group
, taxGroups : List 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 SearchResult
| SetBarcode String
| SetName String
| SetSalesUnits String
| CalculatorMsg Calculator.Msg
| SetNetUnitPrice String
| SetGrossUnitPrice String
| SetGroup String
| SetLocation String
| SetTaxGroup String
-- 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 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 ->
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 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
+29
View File
@@ -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
+36
View File
@@ -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
-103
View File
@@ -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
+14 -3
View File
@@ -1,8 +1,11 @@
import inspect
import json
import sys
from flask import Flask, render_template
from . import (
auth,
db,
entry,
inventory,
@@ -13,11 +16,13 @@ from . import (
def create_app():
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY="dev"
)
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)
db.init_app(app)
auth.init_app(app)
@app.context_processor
def utility_processor():
@@ -26,8 +31,14 @@ def create_app():
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
View File
@@ -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("/")
+2 -4
View File
@@ -1,15 +1,13 @@
import psycopg2
from flask import g
from flask import current_app, g
from pathlib import Path
from psycopg2.extras import RealDictCursor
def get_db():
if "db" not in g:
# TODO: Make this configurable and use a default that works
# on the pool computers.
g.db = psycopg2.connect("host=localhost dbname=garfield")
g.db = psycopg2.connect(current_app.config["DB_CONNECTION_STRING"])
run_query_on(g.db, "add_views.sql", None)
return g.db
@@ -11,6 +11,7 @@ SELECT
inventory_items.available,
inventory_items.item_group,
inventory_items.location,
inventory_items.tax_group,
inventory_item_groups.group_name,
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,
@@ -57,3 +58,56 @@ FROM garfield.inventory_items
) m USING (item_id)
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
+26
View File
@@ -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
group_id,
group_name
group_id AS id,
group_name AS name
FROM garfield.inventory_item_groups
ORDER BY
group_name ASC
+4
View File
@@ -0,0 +1,4 @@
SELECT
location_id AS id,
location_name AS name
FROM garfield.locations
+9
View File
@@ -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
*
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)
AND available
ORDER BY
+49
View File
@@ -0,0 +1,49 @@
WITH
most_recent_sales AS (
SELECT DISTINCT ON (inventory_line)
inventory_line, snack_sales_log_id, snack_sales_log_timestamp AS most_recent_sale
FROM garfield.snack_sales_log
ORDER BY inventory_line ASC, snack_sales_log_timestamp DESC
),
enhanced_overview1 AS (
SELECT
inventory_items.item_id,
inventory_items.item_barcode,
inventory_items.name,
units_left,
inventory_items.sales_units,
correction_delta,
location_name,
location,
CASE
WHEN snack_sales_log_id IS NULL THEN 0
ELSE sales / (EXTRACT(EPOCH FROM (
CASE
WHEN units_left <= 0 THEN most_recent_sale
ELSE NOW()
END
)) - EXTRACT(EPOCH FROM bought)) * 24 * 3600
END AS per_day
FROM garfield.inventory_item_overview
LEFT JOIN garfield.inventory_items USING (item_id)
LEFT JOIN most_recent_sales ON item_id = inventory_line
),
enhanced_overview2 AS (
SELECT
*,
CASE
WHEN per_day = 0 THEN NULL
ELSE GREATEST(0, units_left / per_day)
END AS days_left
FROM enhanced_overview1
)
SELECT
*,
CASE
WHEN days_left IS NULL THEN NULL
ELSE GREATEST(0, (60 - days_left) * per_day)
END AS for_two_months
FROM enhanced_overview2
WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
ORDER BY days_left ASC, per_day DESC
+17
View File
@@ -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
+5
View File
@@ -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));
+4
View File
@@ -0,0 +1,4 @@
{
"SECRET_KEY": "dev",
"DB_CONNECTION_STRING": "host=fsmi-db dbname=garfield"
}
+123
View File
@@ -0,0 +1,123 @@
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()
return render_template(
"entry/new-order.html",
groups=groups,
locations=locations,
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
+41 -3
View File
@@ -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
@@ -6,9 +7,9 @@ from . import db
bp = Blueprint("inventory", __name__, url_prefix="/inventory")
@bp.route("/")
@bp.get("/")
def index():
location = session.get("location", None)
location = current_user.data.location
items = db.run_query("get_inventory_overview.sql", {
"location_id": None if location is None else location["location_id"]
}).fetchall()
@@ -18,6 +19,18 @@ def index():
})
@bp.get("/report")
def read_report():
location = current_user.data.location
items = db.run_query("get_inventory_report.sql", {
"location_id": None if location is None else location["location_id"]
}).fetchall()
return render_template("inventory/read_report.html", **{
"items": items
})
@bp.get("/item/<item_id>")
def read_item(item_id: int):
item = db.run_query("get_item_by_id.sql", {
@@ -80,3 +93,28 @@ def create_correction():
db.get_db().commit()
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)
+6 -6
View File
@@ -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
@@ -11,13 +12,12 @@ def index():
if request.method == "POST":
location_id = request.form.get("location_id", "")
if location_id == "":
session.pop("location", None)
current_user.data.location = None
else:
location = db.run_query("get_location_by_id.sql", {
"location_id": location_id}
).fetchone()
session["location"] = location
"location_id": location_id
}).fetchone()
current_user.data.location = location
locations = db.run_query("get_locations.sql").fetchall()
+80
View File
@@ -0,0 +1,80 @@
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;
}
.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;
}
+19
View File
@@ -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)
+36
View File
@@ -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>
+52
View File
@@ -0,0 +1,52 @@
<!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{{ " 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>
+42
View File
@@ -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 %}
+16
View File
@@ -0,0 +1,16 @@
{% 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 }},
groups: {{ to_json(groups) | safe }},
taxGroups: {{ to_json(tax_groups) | safe }}
}
});
</script>
{% endblock %}
+64
View File
@@ -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,8 +22,7 @@
<th class="--align-left">Einkaufspreis (Netto)</th>
<td>{{ format_currency(item.unit_price) }}</td>
</tr>
<tr>
<th class="--align-left">Kaufdatum</th>
<tr> <th class="--align-left">Kaufdatum</th>
<td>{{ format_date(item.bought) }}</td>
</tr>
<tr>
@@ -121,25 +120,33 @@
<th title="Aktive Snackeinträge">AS</th>
<th>Aktiv?</th>
</tr>
{% for item in same_barcode_items %}
{% for other_item in same_barcode_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><a href="/inventory/item/{{ other_item.item_id }}">{{ other_item.item_id }}</a></td>
<td><code>{{ other_item.item_barcode }}</code></td>
<td>{{ other_item.name }}</td>
<td class="--align-right">{{ format_currency(other_item.unit_price) }}</td>
<td>{{ format_date(other_item.bought) }}</td>
<td>{{ other_item.group_name }} ({{ other_item.item_group }})</td>
<td class="--align-right">{{ other_item.sales_units }}</td>
<td class="--align-right">{% if other_item.correction_delta > 0 %}+{% endif %}{{ other_item.correction_delta }}</td>
<td class="--align-right">{{ other_item.units_left }}</td>
<td class="--centered">
{% if item.active_mappings != 0 %}
{{ item.active_mappings_array | join(", ") }}
{% if other_item.active_mappings != 0 %}
{{ other_item.active_mappings_array | join(", ") }}
{% else %}
-
{% endif %}
</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>
{% endfor %}
</table>
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<table>
<tr>
<th>ID</th>
<th>Barcode</th>
<th>Name</th>
<th>Inventar</th>
<th>Gesamt</th>
<th>Raum</th>
<th>Verbrauch [1/d]</th>
<th title="Estimated Time Until Empty">ETUE [d]</th>
<th>Für 2m</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">{{ item.units_left }}</td>
<td class="--align-right">{{ item.sales_units + item.correction_delta }}</td>
<td>{{ item.location_name }}</td>
<td class="--align-right">{{ item.per_day|round(2) }}</td>
<td class="--align-right">{% if item.days_left != None %}{{ item.days_left|round(1) }}{% endif %}</td>
<td class="--align-right">{% if item.for_two_months %}{{ item.for_two_months|round(1) }}{% endif %}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% block content %}
<form method="POST" action=".">
<form method="POST">
<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 %}
<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 %}
</select>
-2
View File
@@ -1,2 +0,0 @@
pkgs:
pkgs.haskell.packages.ghc924
-17
View File
@@ -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
]
-5
View File
@@ -1,5 +0,0 @@
{ overlays ? [] }:
let
sources = import ./sources.nix;
in
import sources.nixpkgs { inherit overlays; }
-26
View File
@@ -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
View File
@@ -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); }
-71
View File
@@ -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
View File
@@ -1 +0,0 @@
- [ ] Fix date handling in entry
-59
View File
@@ -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
})
-30
View File
@@ -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
-100
View File
@@ -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 %}
-5
View File
@@ -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 %}
-34
View File
@@ -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
View File
@@ -1,6 +1,7 @@
blinker==1.6.2
click==8.1.3
Flask==2.3.2
Flask-Login==0.6.2
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.2
-162
View File
@@ -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 ()
-439
View File
@@ -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)
-32
View File
@@ -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
-273
View File
@@ -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]
-10
View File
@@ -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;
}
-8
View File
@@ -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"]
-14
View File
@@ -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>
-31
View File
@@ -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;
}
}
-2
View File
@@ -1,2 +0,0 @@
main :: IO ()
main = putStrLn "Test suite not yet implemented"