u
This commit is contained in:
209
src/automats/backupAutomat.js
Normal file
209
src/automats/backupAutomat.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @file backupAutomat.js
|
||||
* @description Manages backup status and backup archives (`GET /backup`, `POST /backup/run`).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently.
|
||||
* - UI / Settings: Calls to `actions.load()`, `actions.reload()`, or `actions.runBackup()`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { backup as backupApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} BackupItem
|
||||
* @property {string} name Archive file or directory name
|
||||
* @property {string} path Full storage path
|
||||
* @property {number} totalSizeBytes Total size in bytes
|
||||
* @property {string|null} timestamp Creation timestamp
|
||||
* @property {object|null} [manifest] Backup manifest details
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BackupStatus
|
||||
* @property {boolean} enabled Whether automated backups are enabled
|
||||
* @property {boolean} isRunning Whether a backup is currently executing
|
||||
* @property {string} dbProvider Database provider name (e.g. 'sqlite', 'mssql')
|
||||
* @property {boolean} mssqlWarning Whether an MSSQL warning is present
|
||||
* @property {string} backupDir Root directory where backups are stored
|
||||
* @property {{ hourlyMax: number, dailyMax: number, monthly: string }} retention Retention configuration
|
||||
* @property {{ hourly: number, daily: number, monthly: number }} counts Counts of existing backups
|
||||
* @property {BackupItem|null} [latestHourly] Most recent hourly backup
|
||||
* @property {BackupItem|null} [latestDaily] Most recent daily backup
|
||||
* @property {BackupItem|null} [latestMonthly] Most recent monthly backup
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BackupState
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
|
||||
* @property {string|null} error Last error message
|
||||
* @property {boolean} runningBackup Whether a manual backup run is currently in progress
|
||||
* @property {BackupStatus|null} backupStatus Service status and retention settings
|
||||
* @property {{ counts: { hourly: number, daily: number, monthly: number }, hourly: BackupItem[], daily: BackupItem[], monthly: BackupItem[] }} backups Grouped backup archives
|
||||
* @property {object|null} lastRunResult Manifest/result from the most recent manual backup run
|
||||
*/
|
||||
|
||||
/** @type {BackupState} */
|
||||
const initialState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
runningBackup: false,
|
||||
backupStatus: null,
|
||||
backups: {
|
||||
counts: { hourly: 0, daily: 0, monthly: 0 },
|
||||
hourly: [],
|
||||
daily: [],
|
||||
monthly: [],
|
||||
},
|
||||
lastRunResult: null,
|
||||
};
|
||||
|
||||
let backupAutomat;
|
||||
let inflightLoad = null;
|
||||
|
||||
// #region fetch
|
||||
// MARK: calls GET /api/backup from ./api/index.js
|
||||
async function fetchBackupData() {
|
||||
const data = await backupApi.getStatus();
|
||||
const status = data?.status ?? null;
|
||||
const rawBackups = data?.backups ?? {};
|
||||
|
||||
const hourly = Array.isArray(rawBackups.hourly) ? rawBackups.hourly : [];
|
||||
const daily = Array.isArray(rawBackups.daily) ? rawBackups.daily : [];
|
||||
const monthly = Array.isArray(rawBackups.monthly) ? rawBackups.monthly : [];
|
||||
|
||||
const counts = rawBackups.counts ?? {
|
||||
hourly: hourly.length,
|
||||
daily: daily.length,
|
||||
monthly: monthly.length,
|
||||
};
|
||||
|
||||
return {
|
||||
backupStatus: status,
|
||||
backups: {
|
||||
counts,
|
||||
hourly,
|
||||
daily,
|
||||
monthly,
|
||||
},
|
||||
};
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const actions = {
|
||||
// #region load
|
||||
// MARK: calls GET /api/backup from ./api/index.js
|
||||
/**
|
||||
* Loads backup status and archive listing.
|
||||
* Deduplicates concurrent callers.
|
||||
*
|
||||
* @param {boolean} [silent=false] If true, keeps status as 'ready' without flashing 'loading'
|
||||
* @returns {Promise<{ backupStatus: BackupStatus|null, backups: object }>}
|
||||
*/
|
||||
load(silent = false) {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
if (!silent) {
|
||||
backupAutomat.setState({ status: 'loading', error: null });
|
||||
}
|
||||
inflightLoad = (async () => {
|
||||
try {
|
||||
const { backupStatus, backups } = await fetchBackupData();
|
||||
backupAutomat.setState({
|
||||
backupStatus,
|
||||
backups,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
return { backupStatus, backups };
|
||||
} catch (err) {
|
||||
const error = err?.message ?? String(err);
|
||||
backupAutomat.setState(silent ? { error } : { status: 'failed', error });
|
||||
throw err;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region reload
|
||||
// MARK: calls GET /api/backup from ./api/index.js
|
||||
/**
|
||||
* Silently re-fetches backup status and archives in the background.
|
||||
*/
|
||||
async reload() {
|
||||
return actions.load(true);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region runBackup
|
||||
// MARK: calls POST /api/backup/run from ./api/index.js
|
||||
/**
|
||||
* Triggers an immediate manual backup run on the backend.
|
||||
*
|
||||
* @returns {Promise<object>} The backup run result containing manifest and pruneResult
|
||||
*/
|
||||
async runBackup() {
|
||||
backupAutomat.setState({ runningBackup: true, error: null });
|
||||
try {
|
||||
const result = await backupApi.run();
|
||||
backupAutomat.setState({ lastRunResult: result });
|
||||
await actions.reload();
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err?.message ?? String(err);
|
||||
backupAutomat.setState({ error });
|
||||
throw err;
|
||||
} finally {
|
||||
backupAutomat.setState({ runningBackup: false });
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Resets the automat state to initial empty values.
|
||||
*/
|
||||
clear() {
|
||||
backupAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
backupAutomat = new Automat(initialState, actions, { name: 'backup' });
|
||||
|
||||
// #region sseListener
|
||||
if (sseAutomat?.actions?.on) {
|
||||
sseAutomat.actions.on('backup', () => {
|
||||
void backupAutomat.actions.reload().catch(() => {});
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region authSync
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
backupAutomat.actions.clear();
|
||||
} else {
|
||||
void backupAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
// #endregion
|
||||
|
||||
export default backupAutomat;
|
||||
@@ -638,6 +638,37 @@ export function createDeliveryEditorAutomat(options = {}) {
|
||||
...initialState,
|
||||
});
|
||||
},
|
||||
|
||||
/** Serializes the delivery into a VFS draft manifest format. @returns {object} */
|
||||
getManifest() {
|
||||
const state = automat.state;
|
||||
const rawLines = state.lines ?? state.lineitems ?? [];
|
||||
const lines = rawLines
|
||||
.filter((l) => Boolean(l && (l.sku || l.name || l.amount != null || l.netprice != null || l.net != null)))
|
||||
.map((l) => ({
|
||||
sku: l.sku || null,
|
||||
name: l.name || null,
|
||||
amount: l.amount != null ? Number(l.amount) : null,
|
||||
pu: l.pu != null ? Number(l.pu) : 1,
|
||||
unit: l.unit || null,
|
||||
netprice: l.netprice != null ? Number(l.netprice) : (l.net != null ? Number(l.net) : null),
|
||||
taxrate: l.taxrate != null ? Number(l.taxrate) : (l.tax != null ? Number(l.tax) : null),
|
||||
}));
|
||||
|
||||
return {
|
||||
manifest: {
|
||||
kind: 'delivery',
|
||||
id: null,
|
||||
date: state.date || null,
|
||||
partyId: state.partyId ?? state.deliveryEntity ?? null,
|
||||
externalNumber: state.externalNumber ?? state.external_number ?? null,
|
||||
lines,
|
||||
items: lines,
|
||||
notes: state.notes ?? [],
|
||||
attachments: (state.attachments ?? []).map((a) => ({ name: a.name, type: a.type })),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
automat = new Automat(initialState, actions, {
|
||||
|
||||
190
src/automats/draftSync.js
Normal file
190
src/automats/draftSync.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file draftSync.js
|
||||
* @description Virtual Drafts synchronizer for VFS drafts (drafts/quote, drafts/order, drafts/delivery).
|
||||
* Connects frontend draft automats (quoteDraftAutomat, orderDraftAutomat, deliveryEditorAutomat)
|
||||
* with backend VFS requests and broadcasts.
|
||||
*
|
||||
* Incoming Signals:
|
||||
* - SSE 'draft:request': Backend requests a draft (read or list).
|
||||
* - SSE 'draft': Backend broadcasts an external update to a draft.
|
||||
*
|
||||
* Outgoing Calls:
|
||||
* - POST /api/vfs/drafts/response: Responds to backend requests with draft content or file list.
|
||||
*/
|
||||
|
||||
import { deliveryEditorAutomat, createEmptyLineitem } from './deliveryEditorAutomat';
|
||||
import { quoteDraftAutomat, orderDraftAutomat } from './manifestDraftAutomat';
|
||||
import { vfs } from '../api/vfs';
|
||||
import { getStoredClientId } from '../api/http';
|
||||
import { getSseClientId } from './sseAutomat';
|
||||
|
||||
/**
|
||||
* Normalizes a draft filename/path to its canonical key ('quote', 'order', 'delivery').
|
||||
* Handles paths like 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'quote', etc.
|
||||
*
|
||||
* @param {string} [name]
|
||||
* @returns {string} Normalized key
|
||||
*/
|
||||
export function normalizeDraftName(name) {
|
||||
if (!name) return '';
|
||||
return String(name)
|
||||
.trim()
|
||||
.replace(/^\/?(api\/)?vfs\//, '')
|
||||
.replace(/^drafts\//, '')
|
||||
.replace(/\.json$/, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported virtual draft names.
|
||||
*/
|
||||
export const SUPPORTED_DRAFTS = ['quote', 'order', 'delivery'];
|
||||
|
||||
/**
|
||||
* Timestamps of last modifications per draft.
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
const draftTimestamps = new Map();
|
||||
|
||||
/**
|
||||
* Resolves active draft content for a given draft name from its managing automat.
|
||||
*
|
||||
* @param {string} rawName
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getActiveDraftContent(rawName) {
|
||||
const name = normalizeDraftName(rawName);
|
||||
if (name === 'quote') {
|
||||
return quoteDraftAutomat.actions.getManifest();
|
||||
}
|
||||
if (name === 'order') {
|
||||
return orderDraftAutomat.actions.getManifest();
|
||||
}
|
||||
if (name === 'delivery') {
|
||||
return deliveryEditorAutomat.actions.getManifest();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the list of virtual draft files for the VFS listing.
|
||||
*
|
||||
* @returns {Array<{ name: string, size: number, updated_at: string }>}
|
||||
*/
|
||||
export function getActiveDraftsList() {
|
||||
const now = new Date().toISOString();
|
||||
return SUPPORTED_DRAFTS.map((name) => {
|
||||
const content = getActiveDraftContent(name);
|
||||
let size = 0;
|
||||
if (content != null) {
|
||||
try {
|
||||
size = JSON.stringify(content).length;
|
||||
} catch {}
|
||||
}
|
||||
const updatedAt = draftTimestamps.get(name) || (size > 0 ? now : '2026-10-14T00:00:00.000Z');
|
||||
return {
|
||||
name,
|
||||
size,
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies incoming draft content update to the managing automat.
|
||||
*
|
||||
* @param {string} rawName
|
||||
* @param {object|string} rawContent
|
||||
*/
|
||||
export function applyDraftUpdate(rawName, rawContent) {
|
||||
const name = normalizeDraftName(rawName);
|
||||
if (!name) return;
|
||||
|
||||
let content = rawContent;
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
content = JSON.parse(content);
|
||||
} catch (err) {
|
||||
console.error(`[draft] failed to parse incoming draft string for ${name}:`, err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
draftTimestamps.set(name, new Date().toISOString());
|
||||
|
||||
try {
|
||||
if (name === 'quote') {
|
||||
quoteDraftAutomat.actions.setManifest(content);
|
||||
} else if (name === 'order') {
|
||||
orderDraftAutomat.actions.setManifest(content);
|
||||
} else if (name === 'delivery') {
|
||||
const manifest = content?.manifest || content || {};
|
||||
const rawLines = manifest.lines ?? manifest.lineitems ?? manifest.items ?? [];
|
||||
const lines = Array.isArray(rawLines)
|
||||
? rawLines.map((l) => (l && l.key ? l : createEmptyLineitem(l)))
|
||||
: [];
|
||||
const partyId = manifest.partyId ?? manifest.deliveryEntity ?? manifest.supplierId ?? null;
|
||||
const externalNumber = manifest.externalNumber ?? manifest.external_number ?? null;
|
||||
|
||||
deliveryEditorAutomat.actions.setDelivery({
|
||||
...manifest,
|
||||
partyId,
|
||||
deliveryEntity: partyId,
|
||||
externalNumber,
|
||||
external_number: externalNumber,
|
||||
lines,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[draft] error applying draft update to ${name} automat:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming 'draft:request' SSE event from backend.
|
||||
*
|
||||
* @param {{ requestId: string, action: 'read'|'list', name?: string, target?: string }} payload
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function handleDraftRequest(payload) {
|
||||
if (!payload || !payload.requestId) return;
|
||||
const { requestId, action, name, target } = payload;
|
||||
|
||||
try {
|
||||
if (action === 'read') {
|
||||
const draftName = normalizeDraftName(name || target);
|
||||
const content = getActiveDraftContent(draftName);
|
||||
if (content != null) {
|
||||
await vfs.respondDraft({ requestId, content });
|
||||
} else {
|
||||
await vfs.respondDraft({ requestId, error: 'Draft not open' });
|
||||
}
|
||||
} else if (action === 'list') {
|
||||
const files = getActiveDraftsList();
|
||||
await vfs.respondDraft({ requestId, files });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[draft] error handling draft:request:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming 'draft' SSE broadcast from backend.
|
||||
*
|
||||
* @param {{ sender?: string, action: string, name?: string, target?: string, content?: any }} payload
|
||||
*/
|
||||
export function handleDraftEvent(payload) {
|
||||
if (!payload) return;
|
||||
const { sender, action, name, target, content } = payload;
|
||||
|
||||
const myClientId = getStoredClientId() || (typeof getSseClientId === 'function' ? getSseClientId() : null);
|
||||
if (sender && myClientId && sender === myClientId) {
|
||||
// Ignore self-echo
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'update') {
|
||||
const draftName = normalizeDraftName(name || target);
|
||||
applyDraftUpdate(draftName, content);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
export * from './deliveryEditorAutomat';
|
||||
export * from './manifestDraftAutomat';
|
||||
export * from './draftSync';
|
||||
|
||||
export { default as authAutomat } from './authAutomat';
|
||||
export * from './authAutomat';
|
||||
@@ -85,6 +87,7 @@ export { default as entityAutomat } from './entityAutomat';
|
||||
export { default as customergroupAutomat } from './customergroupAutomat';
|
||||
export { default as numberAutomat } from './numberAutomat';
|
||||
export { default as accountAutomat } from './accountAutomat';
|
||||
export { default as backupAutomat } from './backupAutomat';
|
||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||
|
||||
|
||||
|
||||
235
src/automats/manifestDraftAutomat.js
Normal file
235
src/automats/manifestDraftAutomat.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @file manifestDraftAutomat.js
|
||||
* @description Out-of-tree state automats for in-progress quotes and orders (quoteDraftAutomat, orderDraftAutomat).
|
||||
* Preserves draft state across view navigation and syncs bidirectionally with VFS virtual draft files.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { selectCompanyTaxrate } from './companyAutomat';
|
||||
import artikelAutomat, { getArticle } from './artikelAutomat';
|
||||
import {
|
||||
emptyLine,
|
||||
lineFromApi,
|
||||
dateInputValue,
|
||||
parseNum,
|
||||
} from '../components/manifest/manifestConfig';
|
||||
|
||||
/**
|
||||
* Creates the initial empty draft state for a manifest kind.
|
||||
*
|
||||
* @param {'quote'|'order'} kind
|
||||
* @returns {object} Initial state
|
||||
*/
|
||||
export function createInitialManifestDraftState(kind) {
|
||||
return {
|
||||
kind,
|
||||
mode: 'create',
|
||||
id: null,
|
||||
date: '',
|
||||
partyId: null,
|
||||
number: '',
|
||||
externalNumber: '',
|
||||
lines: [emptyLine()],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Automat instance managing draft state for a manifest kind.
|
||||
*
|
||||
* @param {{ kind: 'quote'|'order', name: string }} options
|
||||
* @returns {Automat}
|
||||
*/
|
||||
export function createManifestDraftAutomat({ kind, name }) {
|
||||
let automat;
|
||||
|
||||
const actions = {
|
||||
/** @param {string} date */
|
||||
setDate(date) {
|
||||
automat.setState({ date: dateInputValue(date) });
|
||||
},
|
||||
|
||||
/** @param {number|string|null} partyId */
|
||||
setPartyId(partyId) {
|
||||
automat.setState({ partyId: partyId != null ? partyId : null });
|
||||
},
|
||||
|
||||
/** @param {string} externalNumber */
|
||||
setExternalNumber(externalNumber) {
|
||||
automat.setState({ externalNumber: externalNumber != null ? String(externalNumber) : '' });
|
||||
},
|
||||
|
||||
/** @param {string} number */
|
||||
setNumber(number) {
|
||||
automat.setState({ number: number != null ? String(number) : '' });
|
||||
},
|
||||
|
||||
/** @param {Array<object>} lines */
|
||||
setLines(lines) {
|
||||
const list = Array.isArray(lines) && lines.length > 0 ? lines : [emptyLine()];
|
||||
automat.setState({ lines: list });
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates an existing line by key.
|
||||
* @param {string} key
|
||||
* @param {string|object} patchOrField
|
||||
* @param {*} [value]
|
||||
*/
|
||||
updateLine(key, patchOrField, value) {
|
||||
const current = automat.state.lines || [];
|
||||
const next = current.map((line) => {
|
||||
if (line.key !== key) return line;
|
||||
if (typeof patchOrField === 'object' && patchOrField != null) {
|
||||
return { ...line, ...patchOrField };
|
||||
}
|
||||
return { ...line, [patchOrField]: value };
|
||||
});
|
||||
automat.setState({ lines: next });
|
||||
},
|
||||
|
||||
/** @param {object} [line] */
|
||||
addLine(line = null) {
|
||||
const current = automat.state.lines || [];
|
||||
const newLine = line ? { ...emptyLine(), ...line } : emptyLine();
|
||||
automat.setState({ lines: [...current, newLine] });
|
||||
},
|
||||
|
||||
/** @param {string} key */
|
||||
removeLine(key) {
|
||||
const current = automat.state.lines || [];
|
||||
const next = current.filter((l) => l.key !== key);
|
||||
automat.setState({ lines: next.length > 0 ? next : [emptyLine()] });
|
||||
},
|
||||
|
||||
removeAllLines() {
|
||||
automat.setState({ lines: [emptyLine()] });
|
||||
},
|
||||
|
||||
/** @param {Array<object>} attachments */
|
||||
setAttachments(attachments) {
|
||||
automat.setState({ attachments: Array.isArray(attachments) ? attachments : [] });
|
||||
},
|
||||
|
||||
/** Resets draft state back to clean initial state. */
|
||||
reset() {
|
||||
automat.setState(createInitialManifestDraftState(kind));
|
||||
},
|
||||
|
||||
/**
|
||||
* Applies full manifest update (from external agent, VFS, or SSE).
|
||||
* @param {object} content
|
||||
*/
|
||||
setManifest(content) {
|
||||
if (!content) return;
|
||||
const manifest = content.manifest || content;
|
||||
const updates = {};
|
||||
|
||||
if (manifest.date !== undefined) {
|
||||
updates.date = dateInputValue(manifest.date);
|
||||
}
|
||||
const party = manifest.partyId ?? manifest.entityId ?? manifest.party_id ?? manifest.supplierId;
|
||||
if (party !== undefined) {
|
||||
updates.partyId = party != null ? party : null;
|
||||
}
|
||||
const ext = manifest.externalNumber ?? manifest.external_number;
|
||||
if (ext !== undefined) {
|
||||
updates.externalNumber = ext != null ? String(ext) : '';
|
||||
}
|
||||
|
||||
const rawLines = manifest.items ?? manifest.lines ?? manifest.lineitems;
|
||||
if (Array.isArray(rawLines)) {
|
||||
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||
const lines = rawLines.map((row) => {
|
||||
const mapped = lineFromApi(row);
|
||||
if (!mapped.amount) mapped.amount = '1';
|
||||
if (!mapped.tax) {
|
||||
mapped.tax = normTax;
|
||||
mapped.taxrate = normTax;
|
||||
}
|
||||
if (!mapped.rebate) mapped.rebate = '0';
|
||||
if (!mapped.name && mapped.sku) {
|
||||
const art = getArticle(mapped.sku);
|
||||
if (art?.name) mapped.name = art.name;
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
updates.lines = lines.length > 0 ? lines : [emptyLine()];
|
||||
|
||||
for (const l of updates.lines) {
|
||||
if (l.sku) {
|
||||
void artikelAutomat.actions.loadArticle(l.sku).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
automat.setState(updates);
|
||||
},
|
||||
|
||||
/**
|
||||
* Serializes current draft state into VFS draft manifest format.
|
||||
* @returns {object} Manifest payload
|
||||
*/
|
||||
getManifest() {
|
||||
const state = automat.state;
|
||||
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||
|
||||
const formattedLines = (state.lines || [])
|
||||
.filter((l) => Boolean(l && (l.sku || l.name || l.net || l.gross)))
|
||||
.map((l) => ({
|
||||
sku: l.sku || null,
|
||||
name: l.name || null,
|
||||
amount: parseNum(l.amount) ?? (l.amount ? Number(l.amount) : 1),
|
||||
net: parseNum(l.net) ?? parseNum(l.netprice) ?? null,
|
||||
gross: parseNum(l.gross) ?? null,
|
||||
taxrate: parseNum(l.tax) ?? parseNum(l.taxrate) ?? Number(normTax),
|
||||
rebate: parseNum(l.rebate) ?? 0,
|
||||
pu: parseNum(l.pu) ?? 1,
|
||||
unit: l.unit || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
manifest: {
|
||||
kind,
|
||||
mode: 'create',
|
||||
id: null,
|
||||
date: state.date || null,
|
||||
partyId: state.partyId ?? null,
|
||||
externalNumber: state.externalNumber || null,
|
||||
lines: formattedLines,
|
||||
items: formattedLines,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
automat = new Automat(createInitialManifestDraftState(kind), actions, {
|
||||
name,
|
||||
persist: false,
|
||||
});
|
||||
|
||||
return automat;
|
||||
}
|
||||
|
||||
/** Shared singleton automat for quote draft. */
|
||||
export const quoteDraftAutomat = createManifestDraftAutomat({
|
||||
kind: 'quote',
|
||||
name: 'quote-draft',
|
||||
});
|
||||
|
||||
/** Shared singleton automat for order draft. */
|
||||
export const orderDraftAutomat = createManifestDraftAutomat({
|
||||
kind: 'order',
|
||||
name: 'order-draft',
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the draft automat for a given manifest kind.
|
||||
* @param {'quote'|'order'} kind
|
||||
* @returns {Automat|null}
|
||||
*/
|
||||
export function getManifestDraftAutomat(kind) {
|
||||
if (kind === 'quote') return quoteDraftAutomat;
|
||||
if (kind === 'order') return orderDraftAutomat;
|
||||
return null;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import attributeAutomat from './attributeAutomat';
|
||||
import propertyAutomat from './propertyAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
import { handleDraftRequest, handleDraftEvent } from './draftSync';
|
||||
|
||||
|
||||
/**
|
||||
@@ -250,6 +251,24 @@ function connect() {
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'draft:request', (event) => {
|
||||
try {
|
||||
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
void handleDraftRequest(payload);
|
||||
} catch (err) {
|
||||
console.error('[sse] draft:request handling failed', err);
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'draft', (event) => {
|
||||
try {
|
||||
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
handleDraftEvent(payload);
|
||||
} catch (err) {
|
||||
console.error('[sse] draft event handling failed', err);
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'open', () => {
|
||||
sseAutomat.setState({ status: 'connected' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user