Compare commits

..

6 Commits

Author SHA1 Message Date
fad9c78f87 Merge remote-tracking branch 'origin/dev' into dev 2025-11-24 23:26:35 +01:00
2cd53b19cb Re-implemented guessing of next document id.
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2025-11-24 23:24:31 +01:00
288acd90f4 Merge branch 'main' into dev 2025-11-24 20:12:08 +01:00
0d202677ad Merge branch 'main' into dev 2025-11-24 16:54:43 +01:00
6bb03f4e04 implemented adding stock items to documents
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2025-11-24 12:49:24 +01:00
438d8d4aad preparing to make items available as document positions
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2025-11-24 09:16:28 +01:00
15 changed files with 142 additions and 148 deletions

View File

@@ -9,7 +9,6 @@ import java.util.regex.Pattern;
public class Constants {
private Constants(){}
public static final Pattern POST_CODE = compile("(.*\\w+.*)\n(.*\\d+.*)\n(\\d{5}) (\\w+)",DOTALL);
public static final String CLONE = "clone";
@@ -21,8 +20,6 @@ public class Constants {
public static final String ERROR_ADDRESS_MISSING = "{0} address does not contain street address / post code / city";
public static final String MOVE = "move";
public static final String PATH_ADD_ITEM = "add_item";
@@ -38,8 +35,6 @@ public class Constants {
public static final String POSITION = "position";
public static final String PROJECT_ID = "project_id";
public static final String TABLE_COMPANY_SETTINGS = "company_settings";
public static final String TABLE_CUSTOMER_SETTINGS = "company_customer_settings";
public static final String TABLE_DOCUMENTS = "documents";
public static final String TABLE_DOCUMENT_TYPES = "document_types";
@@ -47,4 +42,9 @@ public class Constants {
public static final String TABLE_PRICES = "customer_prices";
public static final String TABLE_TEMPLATES = "templates";
public static final String TEMPLATES = "templates";
public static final String TYPE_CONFIRMATION = "confirmation";
public static final String TYPE_INVOICE = "invoice";
public static final String TYPE_OFFER = "offer";
public static final String TYPE_REMINDER = "reminder";
}

View File

@@ -432,8 +432,7 @@ public class DocumentApi extends BaseHandler implements DocumentService {
Document doc = getDocument(docId, user).a;
var companySettings = db.getCompanySettings(doc.companyId(),docType);
var nextNumber = companySettings.nextDocId();
var nextNumber = db.nextDocId(user.language(),doc.companyId(),docType);
Document clone = new Document(
0,
@@ -469,19 +468,17 @@ public class DocumentApi extends BaseHandler implements DocumentService {
if (!json.has(TYPE) || !(json.get(TYPE) instanceof Number docTypeId)) throw missingFieldException(TYPE);
var type = db.getType(docTypeId.intValue());
var customer = Customer.of(customerData);
Template template = new Template(6,companyId,"unknwon",null);
String currency = company.currency();
String sep = company.decimalSeparator();
var settings = db.getCustomerSettings(companyId,type,customer.id());
var newCustomer = settings == null;
if (newCustomer) settings = CustomerSettings.empty();
var companySettings = db.getCompanySettings(companyId,type);
var nextNumber = companySettings.nextDocId();
var nextNumber = db.nextDocId(user.language(), companyId,type);
String lastHead = settings.header();
String lastFooter = settings.footer();
var sender = Sender.of(senderData);
LOG.log(DEBUG,json.toString(2));
var doc = new Document(0,companyId,nextNumber,type, LocalDate.now(), NEW,template,null,lastHead,lastFooter,currency,sep,sender,customer,new PositionList());
var doc = new Document(0,companyId,nextNumber,type, LocalDate.now(), NEW,null,null,lastHead,lastFooter,currency,sep,sender,customer,new PositionList());
var saved = db.save(doc);
if (newCustomer) {
if (customerData.get(CONTACT_ID) instanceof Number contactId) {
@@ -491,7 +488,6 @@ public class DocumentApi extends BaseHandler implements DocumentService {
}
companyService().saveNewCustomer(companyId,customer.id());
}
db.step(companySettings);
return sendContent(ex,saved);
}

View File

@@ -27,8 +27,6 @@ public interface DocumentDb {
CustomerSettings getCustomerSettings(long companyId, Type docType, String customerId) throws UmbrellaException;
CompanySettings getCompanySettings(long companyId, Type docType) throws UmbrellaException;
Collection<Template> getCompanyTemplates(long l) throws UmbrellaException;
Type getType(int typeId) throws UmbrellaException;
@@ -39,12 +37,7 @@ public interface DocumentDb {
Document loadDoc(long docId) throws UmbrellaException;
/**
* decrement the document number
* @param companyId
* @param type
*/
void rollback(long companyId, Type type) throws UmbrellaException;
String nextDocId(String language, long companyId, Type type);
Document save(Document document) throws UmbrellaException;
@@ -52,11 +45,5 @@ public interface DocumentDb {
CustomerSettings save(long companyId, Type docType, String customerId, CustomerSettings settings) throws UmbrellaException;
/**
* increment the document number
* @param settings containing company id and document type
*/
void step(CompanySettings settings);
Pair<Integer> switchPositions(long docId, Pair<Integer> longPair) throws UmbrellaException;
}

View File

@@ -10,6 +10,7 @@ import static de.srsoftware.umbrella.core.Field.*;
import static de.srsoftware.umbrella.core.Field.COMPANY_ID;
import static de.srsoftware.umbrella.core.Field.TAX;
import static de.srsoftware.umbrella.core.Field.UNIT;
import static de.srsoftware.umbrella.core.ModuleRegistry.translator;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.databaseException;
import static de.srsoftware.umbrella.core.model.Document.DEFAULT_THOUSANDS_SEPARATOR;
import static de.srsoftware.umbrella.core.model.Document.State;
@@ -20,6 +21,7 @@ import static java.time.ZoneOffset.UTC;
import de.srsoftware.tools.Pair;
import de.srsoftware.tools.jdbc.Query;
import de.srsoftware.umbrella.core.BaseDb;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.*;
import de.srsoftware.umbrella.documents.model.*;
@@ -28,30 +30,33 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.*;
import java.util.regex.Pattern;
public class SqliteDb implements DocumentDb{
public class SqliteDb extends BaseDb implements DocumentDb{
private static final System.Logger LOG = System.getLogger(SqliteDb.class.getSimpleName());
private final Connection db;
private static final String DB_VERSION = "message_db_version";
private static final int INITIAL_DB_VERSION = 1;
private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\D*)(\\d+)(.*)");
public SqliteDb(Connection conn){
db = conn;
init();
public SqliteDb(Connection connection) {
super(connection);
}
private int createTables() {
createTableDocumentTypes();
createTableTemplates();
createTableDocuments();
createTablePositions();
createTableCustomerPrices();
createTableCompanySettings();
createTableCustomerSettings();
return createTableSettings();
protected int createTables() {
int currentVersion = createSettingsTable();
switch (currentVersion) {
case 0:
createTableDocumentTypes();
createTableTemplates();
createTableDocuments();
createTablePositions();
createTableCustomerPrices();
createTableCustomerSettings();
}
return setCurrentVersion(1);
}
private void createTableCompanySettings() {
/*private void createTableCompanySettings() {
var sql = "CREATE TABLE IF NOT EXISTS {0} ({1} INT NOT NULL, {2} INT NOT NULL, {3} TEXT DEFAULT \"A\", {4} TEXT DEFAULT NULL, {5} INT NOT NULL DEFAULT 1, PRIMARY KEY ({1}, {2}))";
try {
var stmt = db.prepareStatement(format(sql,TABLE_COMPANY_SETTINGS, COMPANY_ID,DOC_TYPE_ID,TYPE_PREFIX,TYPE_SUFFIX,TYPE_NUMBER));
@@ -61,7 +66,7 @@ public class SqliteDb implements DocumentDb{
LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_COMPANY_SETTINGS,e);
throw new RuntimeException(e);
}
}
}*/
private void createTableCustomerPrices() {
var sql = "CREATE TABLE IF NOT EXISTS {0} ({1} INT NOT NULL, {2} VARCHAR(255), {3} VARCHAR(50), {4} INTEGER)";
@@ -82,7 +87,7 @@ public class SqliteDb implements DocumentDb{
stmt.execute();
stmt.close();
} catch (SQLException e) {
LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_COMPANY_SETTINGS,e);
LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_CUSTOMER_SETTINGS,e);
throw new RuntimeException(e);
}
}
@@ -96,8 +101,8 @@ CREATE TABLE IF NOT EXISTS {0} (
{3} INT NOT NULL,
{4} TEXT NOT NULL,
{5} TIMESTAMP NOT NULL,
{6} INT NOT NULL NULL,
{7} INT NOT NULL,
{6} INT NOT NULL,
{7} INT,
{8} VARCHAR(100),
{9} TEXT,
{10} TEXT,
@@ -128,6 +133,12 @@ CREATE TABLE IF NOT EXISTS {0} (
var stmt = db.prepareStatement(format(createTable,TABLE_DOCUMENT_TYPES, ID,NEXT_TYPE, NAME));
stmt.execute();
stmt.close();
insertInto(TABLE_DOCUMENT_TYPES,ID,NEXT_TYPE,NAME)
.values(1,2,TYPE_OFFER)
.values(2,3,TYPE_CONFIRMATION)
.values(3,4,TYPE_INVOICE)
.values(4,4,TYPE_REMINDER)
.execute(db);
} catch (SQLException e) {
LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_DOCUMENT_TYPES,e);
throw new RuntimeException(e);
@@ -241,22 +252,10 @@ CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255)
return pos;
}
@Override
public CompanySettings getCompanySettings(long companyId, Type docType) throws UmbrellaException {
try {
var rs = select(ALL).from(TABLE_COMPANY_SETTINGS).where(COMPANY_ID,equal(companyId)).where(DOC_TYPE_ID,equal(docType.id())).exec(db);
CompanySettings settings = null;
if (rs.next()) settings = CompanySettings.of(rs);
rs.close();
if (settings != null) return settings;
} catch (SQLException ignored) {
}
throw databaseException("Failed to load customer settings (company: {0}, document type: {1})",companyId, docType.name());
}
@Override
public Collection<Template> getCompanyTemplates(long companyId) throws UmbrellaException {
try {
var rs = select(ALL).from(TABLE_TEMPLATES).where(COMPANY_ID,equal(companyId)).exec(db);
var templates = new HashSet<Template>();
while (rs.next()) templates.add(Template.of(rs));
@@ -454,21 +453,28 @@ CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255)
}
@Override
public void rollback(long companyId, Type type) throws UmbrellaException {
public String nextDocId(String language, long companyId, Type type) {
try {
var settings = getCompanySettings(companyId,type);
var numbers = new HashSet<String>();
var rs = select(NUMBER).from(TABLE_DOCUMENTS).where(COMPANY_ID,equal(companyId)).exec(db);
while (rs.next()) numbers.add(rs.getString(NUMBER));
var rs = select(NUMBER).from(TABLE_DOCUMENTS).where(COMPANY_ID,equal(companyId)).where(TYPE_ID,equal(type.id())).sort(ID+" DESC").limit(1).exec(db);
String lastId = null;
if (rs.next()) lastId = rs.getString(1);
rs.close();
var previous = settings.previous();
while (previous.isPresent() && !numbers.contains(previous.get().nextDocId())) previous = previous.get().previous();
previous.ifPresent(this::step);
} catch (SQLException e){
// TODO
if (lastId == null) return translator().translate(language,type.name())+"-0001";
var numeric = NUMBER_PATTERN.matcher(lastId);
if (numeric.find()){
var prefix = numeric.group(1);
var digits = numeric.group(2);
var suffix = numeric.group(3);
var len = digits.length();
while (digits.startsWith("0")) digits = digits.substring(1);
var lid = Long.parseLong(digits)+1;
digits = ""+lid;
while (digits.length()<len) digits = "0"+digits;
return prefix+digits+suffix;
}
return lastId;
} catch (SQLException e) {
throw databaseException("Failed to read last document id");
}
}
@@ -493,8 +499,9 @@ CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255)
var timestamp = doc.date().atStartOfDay(UTC).toInstant().getEpochSecond();
var sender = doc.sender();
var custom = doc.customer();
var templateId = doc.template() == null ? null : doc.template().id();
var stmt = insertInto(TABLE_DOCUMENTS,TYPE_ID,COMPANY_ID, DATE, DELIVERY_DATE,FOOTER,HEAD, NUMBER, STATE, SENDER,TAX_NUMBER,BANK_ACCOUNT,COURT,CUSTOMER,CUSTOMER_EMAIL,CUSTOMER_NUMBER,CUSTOMER_TAX_NUMBER,TEMPLATE_ID,CURRENCY)
.values(doc.type().id(),doc.companyId(),timestamp,doc.delivery(),doc.footer(),doc.head(),doc.number(),doc.state().code(),sender.name(),sender.taxNumber(),sender.bankAccount(),sender.court(),custom.name(),custom.email(),custom.id(),custom.taxNumber(),doc.template().id(),doc.currency())
.values(doc.type().id(),doc.companyId(),timestamp,doc.delivery(),doc.footer(),doc.head(),doc.number(),doc.state().code(),sender.name(),sender.taxNumber(),sender.bankAccount(),sender.court(),custom.name(),custom.email(),custom.id(),custom.taxNumber(),templateId, doc.currency())
.execute(db);
var rs = stmt.getGeneratedKeys();
Long newId = null;
@@ -589,20 +596,6 @@ CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255)
}
}
@Override
public void step(CompanySettings settings) {
try {
update(TABLE_COMPANY_SETTINGS)
.set(TYPE_NUMBER)
.where(COMPANY_ID,equal(settings.companyId())).where(DOC_TYPE_ID,equal(settings.typeId()))
.prepare(db)
.apply(settings.typeNumber()+1)
.close();
} catch (SQLException e) {
LOG.log(WARNING,"Failed to increment doc number");
}
}
@Override
public Pair<Integer> switchPositions(long docId, Pair<Integer> pair) throws UmbrellaException {
try {
@@ -652,6 +645,7 @@ CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255)
var company = rs.getLong(COMPANY_ID);
var name = rs.getString(NAME);
var data = rs.getBytes(TEMPLATE);
if (id == 0) return new Template(0,company,"",null);
return new Template(id,company,name,data);
} catch (SQLException ignored){
return null;

View File

@@ -1,33 +0,0 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.documents.model;
import static de.srsoftware.tools.Optionals.emptyIfNull;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Field.*;
import static de.srsoftware.umbrella.core.Field.COMPANY_ID;
import static de.srsoftware.umbrella.documents.Constants.*;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
public record CompanySettings(long companyId, long typeId, String typePrefix, String typeSuffix, long typeNumber) {
public String nextDocId(){
return typePrefix+typeNumber+typeSuffix;
}
public static CompanySettings of(ResultSet rs) throws SQLException {
var companyId = rs.getLong(COMPANY_ID);
var typeId = rs.getLong(DOC_TYPE_ID);
var typePrefix = emptyIfNull(rs.getString(TYPE_PREFIX));
var typeSuffix = emptyIfNull(rs.getString(TYPE_SUFFIX));
var typeNumber = rs.getLong(TYPE_NUMBER);
return new CompanySettings(companyId,typeId,typePrefix,typeSuffix,typeNumber);
}
public Optional<CompanySettings> previous(){
if (typeNumber<1) return Optional.empty();
return Optional.of(new CompanySettings(companyId,typeId,typePrefix,typeSuffix,typeNumber-1));
}
}

View File

@@ -1,9 +1,26 @@
<script>
import { t } from '../translations.svelte';
let { item, onclick } = $props();
</script>
<fieldset {onclick}>
<legend>{item.code} | {item.name}</legend>
<div>{@html item.description.rendered}</div>
<span>{item.unit_price/100} {item.currency} / {item.unit}</span>
{#if item.properties}
<table>
<tbody>
{#if item.location.name}
<tr>
<th>{t('location')}</th>
<td>{item.location.name}</td>
</tr>
{/if}
{#each Object.entries(item.properties) as [idx,prop]}
<tr>
<th>{prop.name}</th>
<td>{prop.value} {prop.unit}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</fieldset>

View File

@@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { api } from '../../urls.svelte.js';
import { api, post } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js';
@@ -15,13 +15,9 @@
let items = $state(null);
async function loadItems(){
const url = api('items/list');
let data = { company_id: company_id };
const resp = await fetch(url,{
credentials: 'include',
method : 'POST',
body : JSON.stringify(data)
});
const url = api('stock/list');
let data = { company_id };
const resp = await post(url,data);
if (resp.ok){
items = await resp.json();
yikes();
@@ -37,7 +33,7 @@
<div>
<h1>{t('items')}</h1>
{#if items}
{#each items as item,id}
{#each items as item,idx}
<Item item={item} onclick={() => onSelect(item)} />
{/each}
{/if}

View File

@@ -29,14 +29,23 @@
}
function itemSelected(item){
let unit_price = null;
let description = '';
for (let prop of item.properties) {
if (prop.name.toLowerCase().indexOf(t('price').toLowerCase())>-1){
unit_price = 100*prop.value.replace(',','.');
} else {
description += `* ${prop.name}: ${prop.value}\n`;
}
}
select({
item_code : item.code,
title : item.name,
description : item.description.source,
description : description,
amount : 1,
unit : item.unit,
unit_price : item.unit_price,
tax : item.tax
unit : t('pieces'),
unit_price : unit_price
});
}

View File

@@ -1,8 +1,8 @@
<script>
import {onMount} from 'svelte';
import {onMount} from 'svelte';
import {api} from '../../urls.svelte.js';
import {t} from '../../translations.svelte.js';
import {api, post} from '../../urls.svelte.js';
import {t} from '../../translations.svelte.js';
let {
caption,
@@ -16,11 +16,7 @@
async function loadTemplates(){
const url = api('document/templates');
var resp = await fetch(url,{
credentials : 'include',
method : 'POST',
body : JSON.stringify({company:company})
});
var resp = await post(url,{company:company});
if (resp.ok){
templates = await resp.json();
} else {

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import {api} from '../../urls.svelte.js';
import { api, post } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js';
import { user } from '../../user.svelte.js';
@@ -29,11 +29,7 @@
async function addPosition(selected){
const url = api(`document/${doc.id}/position`);
const resp = await fetch(url,{
method : 'POST',
credentials : 'include',
body : JSON.stringify(selected)
});
const resp = await post(url,selected);
if (resp.ok){
doc.positions = await resp.json();
yikes();

View File

@@ -218,6 +218,22 @@ public class SqliteDb extends BaseDb implements StockDb {
}
}
@Override
public Collection<Item> listItemsOf(Company company) {
try {
var owner = company.dbCode();
var rs = select(ALL).from(TABLE_ITEMS).where(OWNER,equal(owner)).exec(db);
var list = new ArrayList<Item>();
while (rs.next()) list.add(Item.of(rs));
rs.close();
for (var item : list) loadProperties(item);
return list;
} catch (SQLException e){
throw databaseException("Failed to load items of {0}",company);
}
}
@Override
public Item loadProperties(Item item){
try {

View File

@@ -13,6 +13,7 @@ public interface StockDb {
Collection<DbLocation> listChildLocations(long parentId);
Collection<DbLocation> listCompanyLocations(Company company);
Collection<Item> listItemsAt(Location location);
Collection<Item> listItemsOf(Company company);
Collection<Property> listProperties();
Collection<DbLocation> listUserLocations(UmbrellaUser userId);
Item loadItem(long id);

View File

@@ -8,6 +8,8 @@ import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Field.ITEM;
import static de.srsoftware.umbrella.core.ModuleRegistry.companyService;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
import static de.srsoftware.umbrella.core.Paths.LIST;
import static de.srsoftware.umbrella.core.Util.mapValues;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
import static de.srsoftware.umbrella.stock.Constants.*;
import static java.lang.System.Logger.Level.WARNING;
@@ -33,6 +35,7 @@ import org.json.JSONObject;
public class StockModule extends BaseHandler implements StockService {
private final StockDb stockDb;
private Comparator<Item> byName = (a,b) -> a.name().compareToIgnoreCase(b.name());
public StockModule(Configuration config) throws UmbrellaException {
super();
@@ -160,6 +163,7 @@ public class StockModule extends BaseHandler implements StockService {
var head = path.pop();
return switch (head) {
case ITEM -> postItem(user.get(), ex);
case LIST -> postItemList(user.get(), path, ex);
case LOCATION -> postLocation(user.get(),ex);
case PROPERTY -> postProperty(user.get(),ex);
case null, default -> super.doPost(path,ex);
@@ -316,6 +320,19 @@ public class StockModule extends BaseHandler implements StockService {
return sendContent(ex,stockDb.save(newItem));
}
private boolean postItemList(UmbrellaUser user, Path path, HttpExchange ex) throws IOException {
var json = json(ex);
if (!json.has(COMPANY_ID) || !(json.get(COMPANY_ID) instanceof Number company_id)) throw missingFieldException(COMPANY_ID);
var company = companyService().get(company_id.longValue());
if (!companyService().membership(company_id.longValue(),user.id())) throw forbidden("You are not a member of {0}!", company.name());
var map = new HashMap<Long,Location>();
var items = stockDb.listItemsOf(company)
.stream()
.peek(item -> item.location(map.computeIfAbsent(item.location().id(), k -> item.location().resolve()))).sorted(byName)
.map(Item::toMap);
return sendContent(ex,items);
}
private boolean postLocation(UmbrellaUser user, HttpExchange ex) throws IOException {
var json = json(ex);
if (!(json.get(NAME) instanceof String name)) throw missingFieldException(NAME);

View File

@@ -204,6 +204,7 @@
"permission_owner": "Besitzer",
"permission_read_only": "lesen",
"phone": "Telefon",
"pieces": "Stück",
"pos": "Pos",
"position": "Position",
"positions": "Positionen",

View File

@@ -204,6 +204,7 @@
"permission_owner": "owner",
"permission_read_only": "read-only",
"phone": "phone",
"pieces": "pieces",
"pos": "pos",
"position": "position",
"positions": "positions",