// ==UserScript== // @name B1 Accounting Tools // @namespace http://tampermonkey.net/ // @homepage https://github.com/martynas2200/b1-labels // @version 2.1.4 // @description Additional accounting and inventory management tools // @author Martynas Miliauskas // @match https://www.b1.lt/* // @match https://site.pro/My-Accounting/* // @match https://site.pro/lt/My-Accounting/* // @icon https://b1.lt/favicon.ico // @connect b1.lt // @connect raw.githubusercontent.com // @downloadURL https://raw.githubusercontent.com/martynas2200/b1-labels/main/dist/accounting.user.js // @updateURL https://raw.githubusercontent.com/martynas2200/b1-labels/main/dist/accounting.user.js // @grant GM.setValue // @grant GM.getValue // @grant unsafeWindow // @grant GM_xmlhttpRequest // @license GNU GPLv3 // @require https://cdn.jsdelivr.net/npm/chart.js // ==/UserScript== (function () { 'use strict'; class ModalService { $uibModal; $rootScope; modalInstance = null; constructor() { const injector = angular.element(document.body).injector(); this.$uibModal = injector.get('$uibModal'); this.$rootScope = injector.get('$rootScope'); } async showModal(config) { const modalScope = this.$rootScope.$new(true); if (config.scopeProperties) { Object.defineProperties(modalScope, Object.entries(config.scopeProperties).reduce((acc, [key, value]) => ({ ...acc, [key]: { get: () => config.scopeProperties[key], set: (v) => config.scopeProperties[key] = v, enumerable: true, configurable: true } }), {})); } this.modalInstance = this.$uibModal.open({ animation: true, template: config.template, scope: modalScope, size: config.size || 'lg', backdrop: config.backdrop || 'static', windowClass: config.windowClass || '', }); modalScope.closeModal = () => { this.modalInstance.close(); modalScope.$destroy(); config.onClose?.(); }; return this.modalInstance.result; } } const MINIMAL_TRANSLATIONS = { en: { barcode: 'Barcode', foundXItems: '{1} items found', multipleItemsFound: 'Multiple items found', itemCreated: 'Item created', itemNotFound: 'Item not found!', itemUpdated: 'Item updated', notAllItemsActive: 'Not all items are active; Do you want to proceed?', oddNumberOfItems: 'Odd number of items; Do you want to proceed?', success: 'Success', error: 'Error', failed: 'Failed', loading: 'Loading...', nlabelsToBePrinted: 'labels to be printed', noData: 'No data to print!', invalidId: 'Invalid ID', longTimeAgo: 'a long time ago', twoMonthsAgo: 'two months ago', oneMonthAgo: 'a month ago', threeWeeksAgo: 'three weeks ago', twoWeeksAgo: 'two weeks ago', daysAgo: '{1} days ago', yesterday: 'yesterday', hoursAgo: '{1} hours ago', minutesAgo: '{1} minutes ago', }, lt: { barcode: 'Barkodas', foundXItems: 'Rasta {1} prekių', multipleItemsFound: 'Rasta daugiau negu viena prekė!', itemCreated: 'Prekė sukurta', itemNotFound: 'Prekė nerasta!', itemUpdated: 'Prekė atnaujinta', notAllItemsActive: 'Ne visos prekės aktyvios, ar norite tęsti?', oddNumberOfItems: 'Nelyginis prekių skaičius, ar norite tęsti?', success: 'Sėkmingai atlikta', error: 'Įvyko klaida', failed: 'Nepavyko', loading: 'Kraunama...', nlabelsToBePrinted: 'etiketės spausdinimui', noData: 'Nepakanka duomenų spausdinimui!', invalidId: 'Neteisingas ID', longTimeAgo: 'labai seniai', twoMonthsAgo: 'prieš du mėnesius', oneMonthAgo: 'prieš mėnesį', threeWeeksAgo: 'prieš tris savaites', twoWeeksAgo: 'prieš dvi savaites', daysAgo: 'prieš {1} dienas', yesterday: 'vakar', hoursAgo: 'prieš {1} valandas', minutesAgo: 'prieš {1} minutes', }, }; const currentLanguage = navigator.language.split('-')[0] === 'lt' ? 'lt' : 'en'; const i18n = (key, values = []) => { let translation = MINIMAL_TRANSLATIONS[currentLanguage]?.[key] ?? MINIMAL_TRANSLATIONS.en[key] ?? key; values.forEach((value, index) => { translation = translation.replace(`{${index + 1}}`, value); }); return translation; }; class AngularServiceLocator { static injector = null; static getInjector() { if (this.injector) { return this.injector; } const appElement = document.querySelector('[ng-app]'); if (!appElement) { throw new Error('Angular app not found'); } this.injector = angular.element(appElement).injector(); return this.injector; } static getService(serviceName) { return this.getInjector().get(serviceName); } } class Request { notifier; items = {}; baseUrl = 'https://site.pro/lt/My-Accounting'; path = '/reference-book/items/search'; csrfToken; headers; turnstileService; constructor(notifier) { this.notifier = notifier; this.turnstileService = AngularServiceLocator.getService('turnstileService'); const csrfTokenElement = document.querySelector('meta[name="csrf-token"]'); this.csrfToken = csrfTokenElement != null ? csrfTokenElement.content : ''; this.headers = { accept: 'application/json, text/plain, */*', 'accept-language': 'en-GB,en;q=0.9,lt-LT;q=0.8,lt;q=0.7,en-US;q=0.6', 'content-type': 'application/json;charset=UTF-8', origin: this.baseUrl, referer: this.baseUrl, 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin', 'x-requested-with': 'XMLHttpRequest', 'x-csrf-token': this.csrfToken, cookie: '', }; } buildRequestBody(rules, pageSize = 20) { return { pageSize, filters: { groupOp: 'AND', rules, }, allSelected: false, asString: '', page: 1, }; } async fetchData(method, path, body) { if (this.csrfToken === '') { console.error('CSRF token is missing'); this.notifier.error('CSRF token is missing'); return; } const pathParts = path.split('/'); pathParts.pop(); this.headers.referer = `${this.baseUrl}${pathParts.join('/')}`; this.getCookies(); try { const response = await fetch(`${this.baseUrl}${path}`, { method, headers: this.headers, body: JSON.stringify(body), }); if (response.ok) { return await response.json(); } else if ('challenge' === response.headers.get('cf-mitigated') || response.status === 403) { if (await this.handleChallenge()) { console.info('Challenge handled, repeat the request'); return await this.fetchData(method, path, body); } } else { console.error('Request failed with status:', response.status); this.notifier.error({ title: i18n('error'), message: response.statusText, }); } } catch (error) { console.error('Error:', error); this.notifier.error('Error: ' + error); } } getCookies() { const cookies = document.cookie.split(';').map((cookie) => cookie.trim()); cookies.forEach((cookie) => { const [name, value] = cookie.split('='); if ([ 'YII_CSRF_TOKEN', 'b1-device_id', '__cookie_law__', '__SITE_PRO_SERVER__', 'site_language', 'site_currency', 'sp_user_fp', 'SSO_REFRESH_TOKEN', 'b1-session_id', 'isLoggedUser-v1', 'LoggedUserHash', 'cf_clearance', 'PHPSESSID', 'SPDOMAIN', ].includes(name.trim())) { this.headers.cookie = this.headers.cookie.length > 0 ? `${this.headers.cookie}; ${name}=${value}` : `${name}=${value}`; } }); } isItDigits(barcode) { return /^\d+$/.test(barcode); } async getItem(barcode) { if (!this.isItDigits(barcode)) { this.notifier.error('Invalid barcode'); return null; } if (Object.keys(this.items).includes(barcode)) { const retrievedAt = this.items[barcode].retrievedAt; if (retrievedAt != null && barcode.length > 10 && new Date().getTime() - retrievedAt.getTime() < 30000) { return { ...this.items[barcode] }; } else if (retrievedAt != null && barcode.length < 10 && new Date().getTime() - retrievedAt.getTime() < 60000) { return { ...this.items[barcode] }; } } const rules = { barcode: { data: barcode, field: 'barcode', op: barcode[0] === '0' ? 'cn' : 'eq', }, }; const body = this.buildRequestBody(rules); const response = await this.fetchData('POST', this.path, body); if (response == null || response.data[0] == null) { return null; } response.data[0].retrievedAt = new Date(); this.items[barcode] = response.data[0]; if (response.data.length > 1) { this.notifier.warning({ title: i18n('multipleItemsFound'), delay: 20000, message: i18n('foundXItems', [response.records]), }); } return response.data[0]; } async getRecentlyModifiedItems(forced = false) { const today = new Date(); today.setHours(0, 0, 0, 0); const body = this.buildRequestBody({ isActive: { data: true, field: 'isActive', op: 'eq' }, modifiedAt: { data: today.toISOString().split('T')[0], field: 'modifiedAt', op: 'gt', }, }, 200); body.sort = { modifiedAt: 'desc' }; const response = await this.fetchData('POST', this.path, body); const recentlySearched = JSON.parse(localStorage.getItem('items') ?? '[]'); const now = new Date(); if (response && response.data) { response.data.forEach((item) => { item.retrievedAt = now; this.items[item.barcode] = item; const found = recentlySearched.find((i) => i.barcode === item.barcode); if (found && found.printedAt && item.modifiedAt && new Date(item.modifiedAt).getTime() <= new Date(found.printedAt).getTime()) { item.noNeedToPrint = true; } }); } if (forced) { this.notifier.info(i18n('foundXItems', [response.records])); } return response.data || []; } async getItemsByIds(ids) { const rules = { id: { data: ids, field: 'id', op: 'in' }, }; const body = this.buildRequestBody(rules, 20); const response = await this.fetchData('POST', this.path, body); if (response && response.data) { response.data.forEach((item) => { item.retrievedAt = new Date(); this.items[item.barcode] = item; }); } if (response.code === 200) { this.notifier.success({ title: i18n('success'), message: i18n('foundXItems', [response.records]), }); } else { this.notifier.error({ title: i18n('error'), message: response.message, }); } return response.data || []; } async getAllItemsBatch(page = 1, pageSize = 100) { const body = this.buildRequestBody({}, pageSize); body.page = page; const response = await this.fetchData('POST', this.path, body); if (response && response.data) { const hasMore = response.data.length === pageSize && page * pageSize < response.records; return { data: response.data, hasMore }; } return { data: [], hasMore: false }; } async getItemMovements(itemId, dateFrom, warehouseId) { const rules = { itemId: { data: itemId, field: 'itemId', op: 'eq' }, warehouseId: { data: warehouseId, field: 'warehouseId', op: 'eq' }, date: { data: dateFrom, field: 'date', op: 'eq' }, }; const body = this.buildRequestBody(rules, 20); body.sort = {}; const response = await this.fetchData('POST', '/warehouse/item-movement/search', body); if (response.code === 200) { this.notifier.success({ title: i18n('success'), message: i18n('foundXItems', [response.records]), }); } else { this.notifier.error({ title: i18n('error'), message: response.message, }); } return response.data || []; } async saveItem(id, data) { if (!this.isItDigits(id)) { this.notifier.error(i18n('invalidId')); return false; } const response = await this.fetchData('POST', `/reference-book/items/update?id=${id}`, data); if (response.code === 200) { this.notifier.success({ title: i18n('itemUpdated'), message: i18n('newPriceIs') + ' ' + data.priceWithVat, delay: 15000, }); } else { this.notifier.error({ title: i18n('failedToUpdateItem'), message: response.message, }); } return response.code === 200; } async quickPriceChange(item) { const price = prompt(i18n('enterNewPrice'), (item.priceWithVat ?? 0).toString()); if (price == null || item.id == null) { this.notifier.info(i18n('error')); return false; } const data = new Object(); data.isActive = true; data.id = item.id; data.priceWithVat = parseFloat(price.replace(',', '.')); if (data.priceWithVat <= 0) { this.notifier.error(i18n('missingPrice')); return false; } data.priceWithoutVat = (data.priceWithVat / 1.21); data.priceWithoutVat = Math.round((data.priceWithoutVat + Number.EPSILON) * 10000) / 10000; item.priceWithVat = data.priceWithVat; item.priceWithoutVat = data.priceWithoutVat; return this.saveItem(data.id, data); } async createItem(data) { const response = await this.fetchData('POST', '/reference-book/items/create', data); if (response.code === 200) { this.notifier.success(i18n('itemCreated')); setTimeout(() => { void this.saveItem(response.data.id, { isActive: true }); }, 400); } else { this.notifier.error({ title: i18n('failedToCreateItem'), message: response.message, }); } return response.code === 200; } async getSales(operationTypeName) { const rules = { operationTypeName: { data: operationTypeName, field: 'operationTypeName', op: 'cn', }, }; const body = this.buildRequestBody(rules, 20); body.sort = { saleDate: 'desc' }; const path = '/warehouse/light-sales/search'; return this.fetchData('POST', path, body); } getSaleItems(lightSaleId) { const rules = { lightSaleId: { field: 'lightSaleId', op: 'eq', data: lightSaleId }, }; const body = this.buildRequestBody(rules, -1); return this.fetchData('POST', '/warehouse/light-sale-items/search', body); } async handleChallenge() { try { await this.turnstileService.render(); console.info('Turnstile challenge passed!'); return true; } catch (error) { console.error('Turnstile challenge failed.', error); return false; } } } var adminCSS = "ng-form textarea.form-control.input-sm.ng-pristine.ng-untouched.ng-valid{height:50px}ng-form label:has(input[name=isActive]){margin-left:-0.5em}ng-form input[name=isActive].ace+span{background-color:#b74635;color:#fff;padding:.75em 5em .75em .5em;border-radius:4px}ng-form input[name=isActive].ace:checked+span{background-color:#fff;color:inherit}ng-form.simplified-form h5.header.blue,ng-form .alert.alert-warning{display:none}.row[ng-controller=\"CashRegisterSaleEdit as c\"] .row .row .col-lg-6{display:none}"; class UINotification { notificationService; constructor() { try { this.notificationService = AngularServiceLocator.getService('Notification'); } catch (error) { console.error('Failed to get Notification service', error); this.notificationService = { info: (options) => alert(options), error: (options) => alert(options), success: (options) => alert(options), warning: (options) => alert(options), primary: (options) => alert(options) }; } } info(options) { this.notificationService.info(options); } error(options) { this.notificationService.error(options); } success(options) { this.notificationService.success(options); } warning(options) { this.notificationService.warning(options); } primary(options) { this.notificationService.primary(options); } } class AdminExtraFunctionality { wereButtonsAdded = false; notification = new UINotification(); currentUrl; constructor() { this.currentUrl = window.location.pathname; this.init(); void this.handleUrlChange(null, this.currentUrl); } init() { void this.handleUrlChange(null, this.currentUrl); this.addStyles(); } async handleUrlChange(previousUrl, currentUrl, tries = 0) { if (this.currentUrl != '/login' && !this.wereButtonsAdded) { this.wereButtonsAdded = this.addButton('Rodyti antkainius', this.listMarkup.bind(this)) && this.addButton('Peržiūrėti prekės judėjimą', this.goToItemMovement.bind(this)); if (currentUrl.includes('/warehouse/sales')) { this.addButton('Trinti pardavimus', this.deleteSales.bind(this)); } if (!this.wereButtonsAdded && tries < 5) { setTimeout(() => { void this.handleUrlChange(null, this.currentUrl, tries + 1); }, 600); } } } getDataRows(notifyOnFail = true) { const dataRows = document.querySelector('.data-rows'); if (dataRows == null) { if (notifyOnFail) { this.notification.error("Įvyko klaida: nepavyko rasti duomenų eilučių"); } throw new Error('Data rows not found'); } return dataRows; } goToItemMovement() { const dataRows = this.getDataRows(); const controller = angular.element(dataRows).controller(); const item = (controller.data ?? controller.grid.data).filter((item) => item._select).pop(); if (item == null) { this.notification.error('Pasirinkite prekę'); return; } if (item.id == null && item.itemId == null) { this.notification.error('Prekės ID nerastas'); return; } const url = new URL('/lt/My-Accounting/warehouse/item-movement', window.location.origin); url.searchParams.append('itemId', item.itemId ?? item.id); url.searchParams.append('itemName', item.name ?? item.itemName); url.searchParams.append('warehouseId', '1'); url.searchParams.append('warehouseName', 'Pagrindinis'); window.open(url.toString(), '_blank'); } calculateMarkup(price, cost) { return ((price - cost) / cost) * 100; } async listMarkup() { if (window.location.pathname !== '/lt/My-Accounting/purchases/edit' && window.location.pathname !== '/lt/My-Accounting/warehouse/purchases/edit') { this.notification.error("Įvyko klaida: ši funkcija veikia tik pirkimo sąskaitų peržiūros lange"); return; } const controller = angular.element(this.getDataRows()).controller(); const modal = new ModalService(); void modal.showModal({ template: ` `, scopeProperties: { data: controller.data, calculateMarkup: this.calculateMarkup, editPrice: this.editPrice.bind(this) }, size: 'lg', backdrop: 'static' }); } addButton(text, callback) { const navbarShortcuts = document.querySelector('.breadcrumbs'); if (navbarShortcuts != null) { const button = document.createElement('button'); button.textContent = text; button.className = 'btn btn-sm'; button.addEventListener('click', callback); navbarShortcuts.appendChild(button); } return navbarShortcuts != null; } async editPrice(item) { const prompt = window.prompt('Įveskite naują kainą', item.itemPriceWithVat?.toString() ?? ''); if (prompt == null) { return; } const newPrice = parseFloat(prompt.replace(',', '.')); if (newPrice <= 0) { this.notification.error('Neteisinga kaina'); return; } else { item.itemPriceWithVat = newPrice; let priceWithoutVat = newPrice / 1.21; priceWithoutVat = Math.round((priceWithoutVat + Number.EPSILON) * 10000) / 10000; const req = new Request(this.notification); const data = { isActive: true, priceWithVat: newPrice, priceWithoutVat: priceWithoutVat }; void req.saveItem(item.itemId.toString(), data); } } async deleteSales() { const itemBarcode = window.prompt('Įveskite prekės barkodą, kurio pardavimo prekes norite ištrinti', ''); if (itemBarcode == null || itemBarcode.trim() === '') { this.notification.error('Neteisingas prekės kodas'); return; } if (window.location.pathname !== '/My-Accounting/warehouse/sales' && window.location.pathname !== '/lt/My-Accounting/warehouse/sales') { this.notification.error("Įvyko klaida: ši funkcija veikia tik pardavimų sąrašo peržiūros lange"); return; } const salesIds = angular.element(this.getDataRows()).controller().data.filter(a => a._select).map(a => a.id); const req = new Request(this.notification); if (salesIds.length === 0) { this.notification.error('Pasirinkite pardavimus'); return; } for (const saleId of salesIds) { const saleItems = await req.fetchData('POST', '/warehouse/sale-items/search', { filters: { rules: { itemBarcode: { field: 'itemBarcode', op: 'eq', data: itemBarcode }, saleId: { field: 'saleId', op: 'eq', data: saleId } } }, page: 1, pageSize: 500, sort: { position: 'asc' }, asString: `[Pardavimas:${saleId}]` }); await new Promise(r => setTimeout(r, Math.random() * 500 + 500)); if (saleItems.records === 0) { this.notification.error(`Nepavyko rasti pardavimo prekių su ID ${saleId} ir prekės kodu ${itemBarcode}`); continue; } const response = await req.fetchData('POST', '/warehouse/sale-items/delete', { ids: saleItems.data.map((saleItem) => saleItem.id) }); if (response.success === false) { this.notification.error(`Nepavyko ištrinti pardavimo prekių su ID ${saleItems.data.map((saleItem) => saleItem.id).join(', ')}`); continue; } this.notification.success(`Sėkmingai ištrintos pardavimo prekės su ID ${saleItems.data.map((saleItem) => saleItem.id).join(', ')}`); await new Promise(r => setTimeout(r, Math.random() * 500 + 800)); } } addStyles() { const styles = document.createElement('style'); styles.innerHTML = adminCSS; document.head.appendChild(styles); } } window.addEventListener('load', () => { void new AdminExtraFunctionality(); }); })();