feat(stock): implement auto-markup for selling price and markup on stock item creation and updates
This commit enhances the stock item management by introducing automatic calculation of selling price and markup based on the cost price. Key changes include: - Integration of `applyDefaultMarkupToPatch` to automatically set `selling_price` and `markup` at 80% of the cost price when they are not explicitly provided during stock item creation and updates. - Updates to the `create` and `update` functions in the stock items service to ensure that existing values are respected and not overwritten if they are already set by the operator. - Tests added to verify the correct behavior of auto-markup during both creation and updates of stock items, ensuring that the functionality works as intended across various scenarios. This feature aims to streamline the pricing process for stock items, reducing manual input and potential errors.
This commit is contained in:
parent
bbfa888cc1
commit
da90706ce0
17 changed files with 4462 additions and 61 deletions
123
nss-hex-nut-din934-grade8-a2.csv
Normal file
123
nss-hex-nut-din934-grade8-a2.csv
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
SKU,PosDescription,Description,Material,Grade,Diameter,Thread,Finish,Pack,UnitPrice
|
||||
AMFNUT93448300,M48 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,48 mm,MF 3.00,Plain,Single Piece,R 59.66
|
||||
AMFNUT93442300,M42 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,42 mm,MF 3.00,Plain,Single Piece,R 41.06
|
||||
AMFNUT93439300,M39 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,39 mm,MF 3.00,Plain,Single Piece,R 36.24
|
||||
AMFNUT93436300,M36 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,36 mm,MF 3.00,Plain,Single Piece,R 33.14
|
||||
AMFNUT93430200,M30 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,30 mm,MF 2.00,Plain,Single Piece,R 18.05
|
||||
AMFNUT93424150,M24 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,24 mm,MF 1.50,Plain,Single Piece,R 5.85
|
||||
AMFNUT93422150,M22 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,22 mm,MF 1.50,Plain,Single Piece,R 5.20
|
||||
AMFNUT93420150,M20 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,20 mm,MF 1.50,Plain,Single Piece,R 2.66
|
||||
AMFNUT93418150,M18 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,18 mm,MF 1.50,Plain,Single Piece,R 3.13
|
||||
AMFNUT93416150,M16 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,16 mm,MF 1.50,Plain,Single Piece,R 1.39
|
||||
AMFNUT93414150,M14 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,14 mm,MF 1.50,Plain,Single Piece,R 1.17
|
||||
AMFNUT93412150,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,MF 1.50,Plain,Single Piece,R 0.65
|
||||
AMFNUT93412125,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,MF 1.25,Plain,Single Piece,R 0.74
|
||||
AMFNUT93410125,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,MF 1.25,Plain,Single Piece,R 0.43
|
||||
AMFNUT93410100,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,MF 1.00,Plain,Single Piece,R 0.49
|
||||
AMFNUT93408100,M8 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,8 mm,MF 1.00,Plain,,R 0.36
|
||||
AMCNUT93472,M72 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,72 mm,Standard,Plain,Single Piece,R 180.70
|
||||
AMCNUT93464,M64 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,64 mm,Standard,Plain,Single Piece,R 88.32
|
||||
AMCNUT93456,M56 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,56 mm,Standard,Plain,Single Piece,R 63.74
|
||||
AMCNUT93452,M52 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,52 mm,Standard,Plain,Single Piece,R 53.32
|
||||
AMCNUT93448H,M48 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,48 mm,Standard,HDG,Single Piece,R 51.42
|
||||
AMCNUT93448,M48 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,48 mm,Standard,Plain,Single Piece,R 43.10
|
||||
AMCNUT93445H,M45 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,45 mm,Standard,HDG,Single Piece,R 42.34
|
||||
AMCNUT93445,M45 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,45 mm,Standard,Plain,Single Piece,R 35.16
|
||||
AMCNUT93442Z,M42 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,Zinc Plated,Single Piece,R 31.76
|
||||
AMCNUT93442H,M42 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,HDG,Single Piece,R 35.15
|
||||
AMCNUT93442,M42 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,Plain,Single Piece,R 28.96
|
||||
AMCNUT93439H,M39 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,39 mm,Standard,HDG,Single Piece,R 26.55
|
||||
AMCNUT93439,M39 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,39 mm,Standard,Plain,Single Piece,R 22.23
|
||||
AMCNUT93436Z,M36 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,Zinc Plated,Single Piece,R 18.16
|
||||
AMCNUT93436H,M36 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,HDG,Single Piece,R 19.99
|
||||
AMCNUT93436,M36 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,Plain,Single Piece,R 16.58
|
||||
AMCNUT93433Z,M33 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,Zinc Plated,Single Piece,R 15.06
|
||||
AMCNUT93433H,M33 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,HDG,Single Piece,R 17.10
|
||||
AMCNUT93433,M33 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,Plain,Single Piece,R 12.99
|
||||
AMCNUT93430ZBAG,M30 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Zinc Plated,,R 1
|
||||
AMCNUT93430Z,M30 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Zinc Plated,Single Piece,R 10.24
|
||||
AMCNUT93430HBAG,M30 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,HDG,,R 1
|
||||
AMCNUT93430H,M30 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,HDG,Single Piece,R 11.27
|
||||
AMCNUT93430BAG,M30 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Plain,,R 1
|
||||
AMCNUT93430,M30 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Plain,Single Piece,R 9.35
|
||||
AMCNUT93427Z,M27 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,Zinc Plated,Single Piece,R 7.74
|
||||
AMCNUT93427H,M27 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,HDG,Single Piece,R 11.91
|
||||
AMCNUT93427,M27 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,Plain,Single Piece,R 7.15
|
||||
AMCNUT93424ZBAG,M24 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Zinc Plated,,R 1
|
||||
AMCNUT93424Z,M24 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Zinc Plated,Single Piece,R 4.26
|
||||
AMCNUT93424HBAG,M24 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,HDG,,R 1
|
||||
AMCNUT93424H,M24 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,HDG,Single Piece,R 4.83
|
||||
AMCNUT93424BAG,M24 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Plain,,R 1
|
||||
AMCNUT93424,M24 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Plain,Single Piece,R 4.06
|
||||
AMCNUT93422Z,M22 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,Zinc Plated,Single Piece,R 3.56
|
||||
AMCNUT93422H,M22 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,HDG,Single Piece,R 5.73
|
||||
AMCNUT93422,M22 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,Plain,Single Piece,R 3.35
|
||||
AMCNUT93420ZBAG,M20 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Zinc Plated,,R 957.00
|
||||
AMCNUT93420Z,M20 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Zinc Plated,Single Piece,R 2.21
|
||||
AMCNUT93420HBAG,M20 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,HDG,,R 1
|
||||
AMCNUT93420H,M20 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,HDG,Single Piece,R 2.70
|
||||
AMCNUT93420BAG,M20 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Plain,,R 888.00
|
||||
AMCNUT93420,M20 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Plain,Single Piece,R 2.05
|
||||
AMCNUT93418Z,M18 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,Zinc Plated,Single Piece,R 2.10
|
||||
AMCNUT93418H,M18 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,HDG,Single Piece,R 3.10
|
||||
AMCNUT93418,M18 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,Plain,Single Piece,R 1.89
|
||||
AMCNUT93416ZBAG,M16 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Zinc Plated,,R 957.00
|
||||
AMCNUT93416Z,M16 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Zinc Plated,Single Piece,R 1.17
|
||||
AMCNUT93416HBAG,M16 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,HDG,,R 1
|
||||
AMCNUT93416H,M16 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,HDG,Single Piece,R 1.41
|
||||
AMCNUT93416BAG,M16 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Plain,,R 875.50
|
||||
AMCNUT93416,M16 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Plain,Single Piece,R 1.07
|
||||
AMCNUT93414Z,M14 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,Zinc Plated,Single Piece,R 1.01
|
||||
AMCNUT93414H,M14 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,HDG,Single Piece,R 1.14
|
||||
AMCNUT93414,M14 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,Plain,Single Piece,R 0.90
|
||||
AMCNUT93412ZBAG,M12 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Zinc Plated,,R 926.10
|
||||
AMCNUT93412Z,M12 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Zinc Plated,Single Piece,R 0.60
|
||||
AMCNUT93412HBAG,M12 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,HDG,,R 1
|
||||
AMCNUT93412H,M12 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,HDG,Single Piece,R 0.75
|
||||
AMCNUT93412BAG,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Plain,,R 875.50
|
||||
AMCNUT93412,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Plain,Single Piece,R 0.57
|
||||
AMCNUT93410ZBAG,M10 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Zinc Plated,,R 1
|
||||
AMCNUT93410Z,M10 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Zinc Plated,Single Piece,R 0.40
|
||||
AMCNUT93410HBAG,M10 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,HDG,,R 1
|
||||
AMCNUT93410H,M10 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,HDG,Single Piece,R 0.51
|
||||
AMCNUT93410BAG,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Plain,,R 875.50
|
||||
AMCNUT93410,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Plain,Single Piece,R 0.38
|
||||
AMCNUT93408ZBAG,M8 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Zinc Plated,,R 926.10
|
||||
AMCNUT93408Z,M8 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Zinc Plated,Single Piece,R 0.18
|
||||
AMCNUT93408HBAG,M8 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,HDG,,R 1
|
||||
AMCNUT93408H,M8 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,HDG,Single Piece,R 0.23
|
||||
AMCNUT93408BAG,M8 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Plain,,R 875.50
|
||||
AMCNUT93408,M8 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Plain,Single Piece,R 0.17
|
||||
AMCNUT93406ZBAG,M6 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Zinc Plated,,R 926.10
|
||||
AMCNUT93406Z,M6 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Zinc Plated,Single Piece,R 0.08
|
||||
AMCNUT93406BAG,M6 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Plain,,R 1
|
||||
AMCNUT93406,M6 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Plain,Single Piece,R 0.08
|
||||
AMCNUT93405Z,M5 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,5 mm,Standard,Zinc Plated,Single Piece,R 0.06
|
||||
AMCNUT93405,M5 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,5 mm,Standard,Plain,Single Piece,R 0.06
|
||||
AMCNUT93404Z,M4 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,4 mm,Standard,Zinc Plated,Single Piece,R 0.06
|
||||
AMCNUT93404,M4 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,4 mm,Standard,Plain,Single Piece,R 0.05
|
||||
AMCNUT93403Z,M3 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,3 mm,Standard,Zinc Plated,Single Piece,R 0.03
|
||||
AMCNUT93403,M3 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,3 mm,Standard,Plain,,R 0.03
|
||||
AMCNUT93402Z,M2 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,2 mm,Standard,Zinc Plated,,R 0.02
|
||||
AMCNUT93402,M2 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,2 mm,Standard,Plain,,R 0.01
|
||||
SM2NUT93439,M39 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,39 mm,Standard,Plain,Single Piece,R 52.18
|
||||
SM2NUT93436,M36 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,36 mm,Standard,Plain,Single Piece,R 39.15
|
||||
SM2NUT93433,M33 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,33 mm,Standard,Plain,Single Piece,R 31.40
|
||||
SM2NUT93430,M30 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,30 mm,Standard,Plain,Single Piece,R 21.62
|
||||
SM2NUT93427,M27 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,27 mm,Standard,Plain,Single Piece,R 15.75
|
||||
SM2NUT93424,M24 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,24 mm,Standard,Plain,Single Piece,R 7.40
|
||||
SM2NUT93422,M22 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,22 mm,Standard,Plain,Single Piece,R 6.01
|
||||
SM2NUT93420,M20 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,20 mm,Standard,Plain,,R 4.60
|
||||
SM2NUT93418,M18 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,18 mm,Standard,Plain,,R 3.98
|
||||
SM2NUT93416,M16 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,16 mm,Standard,Plain,,R 2.23
|
||||
SM2NUT93414,M14 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,14 mm,Standard,Plain,,R 1.80
|
||||
SM2NUT93412,M12 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,12 mm,Standard,Plain,,R 1.15
|
||||
SM2NUT93410,M10 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,10 mm,Standard,Plain,,R 0.77
|
||||
SM2NUT93408,M8 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,8 mm,Standard,Plain,,R 0.39
|
||||
SM2NUT93406,M6 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,6 mm,Standard,Plain,,R 0.17
|
||||
SM2NUT93405,M5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,5 mm,Standard,Plain,,R 0.11
|
||||
SM2NUT93404,M4 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,4 mm,Standard,Plain,,R 0.08
|
||||
SM2NUT934035,M3.5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,3.5 mm,Standard,Plain,,R 0.22
|
||||
SM2NUT93403,M3 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,3 mm,Standard,Plain,,R 0.06
|
||||
SM2NUT934025,M2.5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,2.5 mm,Standard,Plain,,R 0.09
|
||||
SM2NUT93402,M2 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,2 mm,Standard,Plain,,R 0.09
|
||||
|
2586
scripts/nss-fasteners-pb-import.json
Normal file
2586
scripts/nss-fasteners-pb-import.json
Normal file
File diff suppressed because it is too large
Load diff
144
scripts/nss-fasteners-pb-transform.ts
Normal file
144
scripts/nss-fasteners-pb-transform.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Maps scripts/nss-fasteners-single-pieces.csv into PocketBase
|
||||
* `stock_items` records for review.
|
||||
*
|
||||
* Output: scripts/nss-fasteners-pb-import.json
|
||||
*
|
||||
* No data is written to PocketBase yet — this is for the user to
|
||||
* inspect the field mapping before any ingest.
|
||||
*
|
||||
* Run: bun scripts/nss-fasteners-pb-transform.ts
|
||||
* or: npx tsx scripts/nss-fasteners-pb-transform.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const SOURCE_CSV = join(import.meta.dirname, 'nss-fasteners-single-pieces.csv');
|
||||
const OUTPUT_JSON = join(import.meta.dirname, 'nss-fasteners-pb-import.json');
|
||||
|
||||
// === supplied by operator on 2026-09-16 ===
|
||||
const SUPPLIER_NSS_ID = '6xuu7a0pmmubtqd';
|
||||
const CATEGORY_FASTENERS_ID = '4emvaanzogj0586';
|
||||
const MARKUP_PERCENT = 80; // selling_price = cost_price * (1 + MARKUP_PERCENT / 100)
|
||||
|
||||
const SOURCE_URL =
|
||||
'https://nssfasteners.co.za/nuts/hex-nuts/hex-nut-din934.html?gradematerial=771,773';
|
||||
const SOURCE_LABEL = 'NSS Fasteners price list - hex nut DIN934 (Grade 8 + A2)';
|
||||
const EXTRACTED_AT = '2026-09-16'; // YYYY-MM-DD
|
||||
|
||||
type CsvRow = {
|
||||
SKU: string;
|
||||
PosDescription: string;
|
||||
Description: string;
|
||||
Material: string;
|
||||
Grade: string;
|
||||
Diameter: string;
|
||||
Thread: string;
|
||||
Finish: string;
|
||||
Pack: string;
|
||||
UnitPrice: string;
|
||||
};
|
||||
|
||||
const parsePrice = (s: string): number | null => {
|
||||
// "R 59.66" -> 59.66
|
||||
const m = s.match(/([\d.]+)/);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
const toPbRecord = (r: CsvRow) => {
|
||||
const costPrice = parsePrice(r.UnitPrice);
|
||||
const notes = [
|
||||
`Material: ${r.Material}`,
|
||||
`Grade: ${r.Grade}`,
|
||||
`Diameter: ${r.Diameter}`,
|
||||
`Thread: ${r.Thread}`,
|
||||
`Finish: ${r.Finish}`,
|
||||
`Source: ${SOURCE_LABEL}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
|
||||
const costHistory = [
|
||||
{
|
||||
price: costPrice,
|
||||
currency: 'ZAR',
|
||||
source: 'nssfasteners.co.za',
|
||||
extracted_at: EXTRACTED_AT,
|
||||
source_url: SOURCE_URL,
|
||||
qty_basis: 1,
|
||||
extraction_method: 'browser_automation',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
// === required ===
|
||||
name: r.PosDescription,
|
||||
|
||||
// === identifying ===
|
||||
sku: r.SKU,
|
||||
description: r.Description,
|
||||
|
||||
// === sourcing / tracking (user's suggestion) ===
|
||||
location: SOURCE_LABEL,
|
||||
notes,
|
||||
cost_history: costHistory,
|
||||
|
||||
// === pricing ===
|
||||
cost_price: costPrice,
|
||||
// selling_price and markup intentionally left for the user to set
|
||||
// (depends on customer-facing markup policy, not supplier data).
|
||||
|
||||
// === inventory defaults (no source data) ===
|
||||
unit: r.Pack || 'Each',
|
||||
current_qty: 0,
|
||||
min_qty: 0,
|
||||
target_qty: 0,
|
||||
is_active: true,
|
||||
|
||||
// === relations intentionally NULL ===
|
||||
// supplier + category are relations that need IDs the user must look up.
|
||||
// Do not invent them here.
|
||||
supplier: null,
|
||||
category: null,
|
||||
};
|
||||
};
|
||||
|
||||
const csv = readFileSync(SOURCE_CSV, 'utf8').trim();
|
||||
const [headerLine, ...dataLines] = csv.split('\n');
|
||||
const headers = headerLine.split(',') as (keyof CsvRow)[];
|
||||
|
||||
const rows: CsvRow[] = dataLines.map(line => {
|
||||
// naive CSV split - no embedded commas in current dataset, so safe.
|
||||
const cols = line.split(',');
|
||||
const obj = {} as CsvRow;
|
||||
headers.forEach((h, i) => {
|
||||
(obj as Record<string, string>)[h] = cols[i] ?? '';
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const records = rows.map(toPbRecord);
|
||||
|
||||
writeFileSync(
|
||||
OUTPUT_JSON,
|
||||
JSON.stringify(
|
||||
{
|
||||
meta: {
|
||||
source_csv: 'scripts/nss-fasteners-single-pieces.csv',
|
||||
source_url: SOURCE_URL,
|
||||
extracted_at: EXTRACTED_AT,
|
||||
row_count: records.length,
|
||||
pb_collection: 'stock_items',
|
||||
notes:
|
||||
'selling_price and markup are unset (null/0). supplier and category are null — look up IDs separately before ingest.',
|
||||
},
|
||||
records,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`Wrote ${records.length} records to ${OUTPUT_JSON}`);
|
||||
123
scripts/nss-fasteners-pilot.csv
Normal file
123
scripts/nss-fasteners-pilot.csv
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
SKU,PosDescription,Description,Material,Grade,Diameter,Thread,Finish,Pack,UnitPrice
|
||||
AMFNUT93448300,M48 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,48 mm,MF 3.00,Plain,Single Piece,R 59.66
|
||||
AMFNUT93442300,M42 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,42 mm,MF 3.00,Plain,Single Piece,R 41.06
|
||||
AMFNUT93439300,M39 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,39 mm,MF 3.00,Plain,Single Piece,R 36.24
|
||||
AMFNUT93436300,M36 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,36 mm,MF 3.00,Plain,Single Piece,R 33.14
|
||||
AMFNUT93430200,M30 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,30 mm,MF 2.00,Plain,Single Piece,R 18.05
|
||||
AMFNUT93424150,M24 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,24 mm,MF 1.50,Plain,Single Piece,R 5.85
|
||||
AMFNUT93422150,M22 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,22 mm,MF 1.50,Plain,Single Piece,R 5.20
|
||||
AMFNUT93420150,M20 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,20 mm,MF 1.50,Plain,Single Piece,R 2.66
|
||||
AMFNUT93418150,M18 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,18 mm,MF 1.50,Plain,Single Piece,R 3.13
|
||||
AMFNUT93416150,M16 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,16 mm,MF 1.50,Plain,Single Piece,R 1.39
|
||||
AMFNUT93414150,M14 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,14 mm,MF 1.50,Plain,Single Piece,R 1.17
|
||||
AMFNUT93412150,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,MF 1.50,Plain,Single Piece,R 0.65
|
||||
AMFNUT93412125,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,MF 1.25,Plain,Single Piece,R 0.74
|
||||
AMFNUT93410125,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,MF 1.25,Plain,Single Piece,R 0.43
|
||||
AMFNUT93410100,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,MF 1.00,Plain,Single Piece,R 0.49
|
||||
AMFNUT93408100,M8 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,8 mm,MF 1.00,Plain,,R 0.36
|
||||
AMCNUT93472,M72 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,72 mm,Standard,Plain,Single Piece,R 180.70
|
||||
AMCNUT93464,M64 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,64 mm,Standard,Plain,Single Piece,R 88.32
|
||||
AMCNUT93456,M56 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,56 mm,Standard,Plain,Single Piece,R 63.74
|
||||
AMCNUT93452,M52 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,52 mm,Standard,Plain,Single Piece,R 53.32
|
||||
AMCNUT93448H,M48 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,48 mm,Standard,HDG,Single Piece,R 51.42
|
||||
AMCNUT93448,M48 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,48 mm,Standard,Plain,Single Piece,R 43.10
|
||||
AMCNUT93445H,M45 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,45 mm,Standard,HDG,Single Piece,R 42.34
|
||||
AMCNUT93445,M45 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,45 mm,Standard,Plain,Single Piece,R 35.16
|
||||
AMCNUT93442Z,M42 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,Zinc Plated,Single Piece,R 31.76
|
||||
AMCNUT93442H,M42 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,HDG,Single Piece,R 35.15
|
||||
AMCNUT93442,M42 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,Plain,Single Piece,R 28.96
|
||||
AMCNUT93439H,M39 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,39 mm,Standard,HDG,Single Piece,R 26.55
|
||||
AMCNUT93439,M39 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,39 mm,Standard,Plain,Single Piece,R 22.23
|
||||
AMCNUT93436Z,M36 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,Zinc Plated,Single Piece,R 18.16
|
||||
AMCNUT93436H,M36 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,HDG,Single Piece,R 19.99
|
||||
AMCNUT93436,M36 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,Plain,Single Piece,R 16.58
|
||||
AMCNUT93433Z,M33 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,Zinc Plated,Single Piece,R 15.06
|
||||
AMCNUT93433H,M33 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,HDG,Single Piece,R 17.10
|
||||
AMCNUT93433,M33 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,Plain,Single Piece,R 12.99
|
||||
AMCNUT93430ZBAG,M30 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Zinc Plated,,R 1
|
||||
AMCNUT93430Z,M30 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Zinc Plated,Single Piece,R 10.24
|
||||
AMCNUT93430HBAG,M30 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,HDG,,R 1
|
||||
AMCNUT93430H,M30 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,HDG,Single Piece,R 11.27
|
||||
AMCNUT93430BAG,M30 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Plain,,R 1
|
||||
AMCNUT93430,M30 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Plain,Single Piece,R 9.35
|
||||
AMCNUT93427Z,M27 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,Zinc Plated,Single Piece,R 7.74
|
||||
AMCNUT93427H,M27 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,HDG,Single Piece,R 11.91
|
||||
AMCNUT93427,M27 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,Plain,Single Piece,R 7.15
|
||||
AMCNUT93424ZBAG,M24 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Zinc Plated,,R 1
|
||||
AMCNUT93424Z,M24 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Zinc Plated,Single Piece,R 4.26
|
||||
AMCNUT93424HBAG,M24 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,HDG,,R 1
|
||||
AMCNUT93424H,M24 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,HDG,Single Piece,R 4.83
|
||||
AMCNUT93424BAG,M24 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Plain,,R 1
|
||||
AMCNUT93424,M24 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Plain,Single Piece,R 4.06
|
||||
AMCNUT93422Z,M22 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,Zinc Plated,Single Piece,R 3.56
|
||||
AMCNUT93422H,M22 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,HDG,Single Piece,R 5.73
|
||||
AMCNUT93422,M22 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,Plain,Single Piece,R 3.35
|
||||
AMCNUT93420ZBAG,M20 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Zinc Plated,,R 957.00
|
||||
AMCNUT93420Z,M20 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Zinc Plated,Single Piece,R 2.21
|
||||
AMCNUT93420HBAG,M20 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,HDG,,R 1
|
||||
AMCNUT93420H,M20 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,HDG,Single Piece,R 2.70
|
||||
AMCNUT93420BAG,M20 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Plain,,R 888.00
|
||||
AMCNUT93420,M20 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Plain,Single Piece,R 2.05
|
||||
AMCNUT93418Z,M18 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,Zinc Plated,Single Piece,R 2.10
|
||||
AMCNUT93418H,M18 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,HDG,Single Piece,R 3.10
|
||||
AMCNUT93418,M18 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,Plain,Single Piece,R 1.89
|
||||
AMCNUT93416ZBAG,M16 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Zinc Plated,,R 957.00
|
||||
AMCNUT93416Z,M16 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Zinc Plated,Single Piece,R 1.17
|
||||
AMCNUT93416HBAG,M16 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,HDG,,R 1
|
||||
AMCNUT93416H,M16 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,HDG,Single Piece,R 1.41
|
||||
AMCNUT93416BAG,M16 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Plain,,R 875.50
|
||||
AMCNUT93416,M16 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Plain,Single Piece,R 1.07
|
||||
AMCNUT93414Z,M14 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,Zinc Plated,Single Piece,R 1.01
|
||||
AMCNUT93414H,M14 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,HDG,Single Piece,R 1.14
|
||||
AMCNUT93414,M14 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,Plain,Single Piece,R 0.90
|
||||
AMCNUT93412ZBAG,M12 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Zinc Plated,,R 926.10
|
||||
AMCNUT93412Z,M12 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Zinc Plated,Single Piece,R 0.60
|
||||
AMCNUT93412HBAG,M12 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,HDG,,R 1
|
||||
AMCNUT93412H,M12 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,HDG,Single Piece,R 0.75
|
||||
AMCNUT93412BAG,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Plain,,R 875.50
|
||||
AMCNUT93412,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Plain,Single Piece,R 0.57
|
||||
AMCNUT93410ZBAG,M10 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Zinc Plated,,R 1
|
||||
AMCNUT93410Z,M10 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Zinc Plated,Single Piece,R 0.40
|
||||
AMCNUT93410HBAG,M10 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,HDG,,R 1
|
||||
AMCNUT93410H,M10 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,HDG,Single Piece,R 0.51
|
||||
AMCNUT93410BAG,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Plain,,R 875.50
|
||||
AMCNUT93410,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Plain,Single Piece,R 0.38
|
||||
AMCNUT93408ZBAG,M8 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Zinc Plated,,R 926.10
|
||||
AMCNUT93408Z,M8 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Zinc Plated,Single Piece,R 0.18
|
||||
AMCNUT93408HBAG,M8 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,HDG,,R 1
|
||||
AMCNUT93408H,M8 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,HDG,Single Piece,R 0.23
|
||||
AMCNUT93408BAG,M8 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Plain,,R 875.50
|
||||
AMCNUT93408,M8 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Plain,Single Piece,R 0.17
|
||||
AMCNUT93406ZBAG,M6 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Zinc Plated,,R 926.10
|
||||
AMCNUT93406Z,M6 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Zinc Plated,Single Piece,R 0.08
|
||||
AMCNUT93406BAG,M6 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Plain,,R 1
|
||||
AMCNUT93406,M6 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Plain,Single Piece,R 0.08
|
||||
AMCNUT93405Z,M5 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,5 mm,Standard,Zinc Plated,Single Piece,R 0.06
|
||||
AMCNUT93405,M5 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,5 mm,Standard,Plain,Single Piece,R 0.06
|
||||
AMCNUT93404Z,M4 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,4 mm,Standard,Zinc Plated,Single Piece,R 0.06
|
||||
AMCNUT93404,M4 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,4 mm,Standard,Plain,Single Piece,R 0.05
|
||||
AMCNUT93403Z,M3 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,3 mm,Standard,Zinc Plated,Single Piece,R 0.03
|
||||
AMCNUT93403,M3 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,3 mm,Standard,Plain,,R 0.03
|
||||
AMCNUT93402Z,M2 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,2 mm,Standard,Zinc Plated,,R 0.02
|
||||
AMCNUT93402,M2 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,2 mm,Standard,Plain,,R 0.01
|
||||
SM2NUT93439,M39 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,39 mm,Standard,Plain,Single Piece,R 52.18
|
||||
SM2NUT93436,M36 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,36 mm,Standard,Plain,Single Piece,R 39.15
|
||||
SM2NUT93433,M33 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,33 mm,Standard,Plain,Single Piece,R 31.40
|
||||
SM2NUT93430,M30 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,30 mm,Standard,Plain,Single Piece,R 21.62
|
||||
SM2NUT93427,M27 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,27 mm,Standard,Plain,Single Piece,R 15.75
|
||||
SM2NUT93424,M24 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,24 mm,Standard,Plain,Single Piece,R 7.40
|
||||
SM2NUT93422,M22 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,22 mm,Standard,Plain,Single Piece,R 6.01
|
||||
SM2NUT93420,M20 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,20 mm,Standard,Plain,,R 4.60
|
||||
SM2NUT93418,M18 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,18 mm,Standard,Plain,,R 3.98
|
||||
SM2NUT93416,M16 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,16 mm,Standard,Plain,,R 2.23
|
||||
SM2NUT93414,M14 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,14 mm,Standard,Plain,,R 1.80
|
||||
SM2NUT93412,M12 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,12 mm,Standard,Plain,,R 1.15
|
||||
SM2NUT93410,M10 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,10 mm,Standard,Plain,,R 0.77
|
||||
SM2NUT93408,M8 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,8 mm,Standard,Plain,,R 0.39
|
||||
SM2NUT93406,M6 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,6 mm,Standard,Plain,,R 0.17
|
||||
SM2NUT93405,M5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,5 mm,Standard,Plain,,R 0.11
|
||||
SM2NUT93404,M4 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,4 mm,Standard,Plain,,R 0.08
|
||||
SM2NUT934035,M3.5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,3.5 mm,Standard,Plain,,R 0.22
|
||||
SM2NUT93403,M3 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,3 mm,Standard,Plain,,R 0.06
|
||||
SM2NUT934025,M2.5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,2.5 mm,Standard,Plain,,R 0.09
|
||||
SM2NUT93402,M2 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,2 mm,Standard,Plain,,R 0.09
|
||||
|
100
scripts/nss-fasteners-single-pieces.csv
Normal file
100
scripts/nss-fasteners-single-pieces.csv
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
SKU,PosDescription,Description,Material,Grade,Diameter,Thread,Finish,Pack,UnitPrice
|
||||
AMFNUT93448300,M48 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,48 mm,MF 3.00,Plain,Single Piece,R 59.66
|
||||
AMFNUT93442300,M42 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,42 mm,MF 3.00,Plain,Single Piece,R 41.06
|
||||
AMFNUT93439300,M39 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,39 mm,MF 3.00,Plain,Single Piece,R 36.24
|
||||
AMFNUT93436300,M36 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,36 mm,MF 3.00,Plain,Single Piece,R 33.14
|
||||
AMFNUT93430200,M30 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,30 mm,MF 2.00,Plain,Single Piece,R 18.05
|
||||
AMFNUT93424150,M24 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,24 mm,MF 1.50,Plain,Single Piece,R 5.85
|
||||
AMFNUT93422150,M22 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,22 mm,MF 1.50,Plain,Single Piece,R 5.20
|
||||
AMFNUT93420150,M20 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,20 mm,MF 1.50,Plain,Single Piece,R 2.66
|
||||
AMFNUT93418150,M18 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,18 mm,MF 1.50,Plain,Single Piece,R 3.13
|
||||
AMFNUT93416150,M16 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,16 mm,MF 1.50,Plain,Single Piece,R 1.39
|
||||
AMFNUT93414150,M14 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,14 mm,MF 1.50,Plain,Single Piece,R 1.17
|
||||
AMFNUT93412150,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,MF 1.50,Plain,Single Piece,R 0.65
|
||||
AMFNUT93412125,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,MF 1.25,Plain,Single Piece,R 0.74
|
||||
AMFNUT93410125,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,MF 1.25,Plain,Single Piece,R 0.43
|
||||
AMFNUT93410100,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,MF 1.00,Plain,Single Piece,R 0.49
|
||||
AMFNUT93408100,M8 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,8 mm,MF 1.00,Plain,,R 0.36
|
||||
AMCNUT93472,M72 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,72 mm,Standard,Plain,Single Piece,R 180.70
|
||||
AMCNUT93464,M64 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,64 mm,Standard,Plain,Single Piece,R 88.32
|
||||
AMCNUT93456,M56 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,56 mm,Standard,Plain,Single Piece,R 63.74
|
||||
AMCNUT93452,M52 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,52 mm,Standard,Plain,Single Piece,R 53.32
|
||||
AMCNUT93448H,M48 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,48 mm,Standard,HDG,Single Piece,R 51.42
|
||||
AMCNUT93448,M48 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,48 mm,Standard,Plain,Single Piece,R 43.10
|
||||
AMCNUT93445H,M45 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,45 mm,Standard,HDG,Single Piece,R 42.34
|
||||
AMCNUT93445,M45 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,45 mm,Standard,Plain,Single Piece,R 35.16
|
||||
AMCNUT93442Z,M42 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,Zinc Plated,Single Piece,R 31.76
|
||||
AMCNUT93442H,M42 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,HDG,Single Piece,R 35.15
|
||||
AMCNUT93442,M42 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,42 mm,Standard,Plain,Single Piece,R 28.96
|
||||
AMCNUT93439H,M39 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,39 mm,Standard,HDG,Single Piece,R 26.55
|
||||
AMCNUT93439,M39 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,39 mm,Standard,Plain,Single Piece,R 22.23
|
||||
AMCNUT93436Z,M36 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,Zinc Plated,Single Piece,R 18.16
|
||||
AMCNUT93436H,M36 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,HDG,Single Piece,R 19.99
|
||||
AMCNUT93436,M36 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,36 mm,Standard,Plain,Single Piece,R 16.58
|
||||
AMCNUT93433Z,M33 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,Zinc Plated,Single Piece,R 15.06
|
||||
AMCNUT93433H,M33 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,HDG,Single Piece,R 17.10
|
||||
AMCNUT93433,M33 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,33 mm,Standard,Plain,Single Piece,R 12.99
|
||||
AMCNUT93430Z,M30 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Zinc Plated,Single Piece,R 10.24
|
||||
AMCNUT93430H,M30 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,HDG,Single Piece,R 11.27
|
||||
AMCNUT93430,M30 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,30 mm,Standard,Plain,Single Piece,R 9.35
|
||||
AMCNUT93427Z,M27 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,Zinc Plated,Single Piece,R 7.74
|
||||
AMCNUT93427H,M27 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,HDG,Single Piece,R 11.91
|
||||
AMCNUT93427,M27 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,27 mm,Standard,Plain,Single Piece,R 7.15
|
||||
AMCNUT93424Z,M24 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Zinc Plated,Single Piece,R 4.26
|
||||
AMCNUT93424H,M24 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,HDG,Single Piece,R 4.83
|
||||
AMCNUT93424,M24 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,24 mm,Standard,Plain,Single Piece,R 4.06
|
||||
AMCNUT93422Z,M22 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,Zinc Plated,Single Piece,R 3.56
|
||||
AMCNUT93422H,M22 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,HDG,Single Piece,R 5.73
|
||||
AMCNUT93422,M22 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,22 mm,Standard,Plain,Single Piece,R 3.35
|
||||
AMCNUT93420Z,M20 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Zinc Plated,Single Piece,R 2.21
|
||||
AMCNUT93420H,M20 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,HDG,Single Piece,R 2.70
|
||||
AMCNUT93420,M20 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,20 mm,Standard,Plain,Single Piece,R 2.05
|
||||
AMCNUT93418Z,M18 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,Zinc Plated,Single Piece,R 2.10
|
||||
AMCNUT93418H,M18 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,HDG,Single Piece,R 3.10
|
||||
AMCNUT93418,M18 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,18 mm,Standard,Plain,Single Piece,R 1.89
|
||||
AMCNUT93416Z,M16 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Zinc Plated,Single Piece,R 1.17
|
||||
AMCNUT93416H,M16 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,HDG,Single Piece,R 1.41
|
||||
AMCNUT93416,M16 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,16 mm,Standard,Plain,Single Piece,R 1.07
|
||||
AMCNUT93414Z,M14 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,Zinc Plated,Single Piece,R 1.01
|
||||
AMCNUT93414H,M14 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,HDG,Single Piece,R 1.14
|
||||
AMCNUT93414,M14 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,14 mm,Standard,Plain,Single Piece,R 0.90
|
||||
AMCNUT93412Z,M12 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Zinc Plated,Single Piece,R 0.60
|
||||
AMCNUT93412H,M12 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,HDG,Single Piece,R 0.75
|
||||
AMCNUT93412,M12 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,12 mm,Standard,Plain,Single Piece,R 0.57
|
||||
AMCNUT93410Z,M10 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Zinc Plated,Single Piece,R 0.40
|
||||
AMCNUT93410H,M10 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,HDG,Single Piece,R 0.51
|
||||
AMCNUT93410,M10 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,10 mm,Standard,Plain,Single Piece,R 0.38
|
||||
AMCNUT93408Z,M8 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Zinc Plated,Single Piece,R 0.18
|
||||
AMCNUT93408H,M8 NUT 8 HDG,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,HDG,Single Piece,R 0.23
|
||||
AMCNUT93408,M8 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,8 mm,Standard,Plain,Single Piece,R 0.17
|
||||
AMCNUT93406Z,M6 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Zinc Plated,Single Piece,R 0.08
|
||||
AMCNUT93406,M6 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,6 mm,Standard,Plain,Single Piece,R 0.08
|
||||
AMCNUT93405Z,M5 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,5 mm,Standard,Zinc Plated,Single Piece,R 0.06
|
||||
AMCNUT93405,M5 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,5 mm,Standard,Plain,Single Piece,R 0.06
|
||||
AMCNUT93404Z,M4 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,4 mm,Standard,Zinc Plated,Single Piece,R 0.06
|
||||
AMCNUT93404,M4 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,4 mm,Standard,Plain,Single Piece,R 0.05
|
||||
AMCNUT93403Z,M3 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,3 mm,Standard,Zinc Plated,Single Piece,R 0.03
|
||||
AMCNUT93403,M3 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,3 mm,Standard,Plain,,R 0.03
|
||||
AMCNUT93402Z,M2 NUT 8 ZINC PLATED,Hex Nut DIN934,Hi-Tensile,8,2 mm,Standard,Zinc Plated,,R 0.02
|
||||
AMCNUT93402,M2 NUT 8 PLN,Hex Nut DIN934,Hi-Tensile,8,2 mm,Standard,Plain,,R 0.01
|
||||
SM2NUT93439,M39 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,39 mm,Standard,Plain,Single Piece,R 52.18
|
||||
SM2NUT93436,M36 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,36 mm,Standard,Plain,Single Piece,R 39.15
|
||||
SM2NUT93433,M33 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,33 mm,Standard,Plain,Single Piece,R 31.40
|
||||
SM2NUT93430,M30 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,30 mm,Standard,Plain,Single Piece,R 21.62
|
||||
SM2NUT93427,M27 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,27 mm,Standard,Plain,Single Piece,R 15.75
|
||||
SM2NUT93424,M24 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,24 mm,Standard,Plain,Single Piece,R 7.40
|
||||
SM2NUT93422,M22 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,22 mm,Standard,Plain,Single Piece,R 6.01
|
||||
SM2NUT93420,M20 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,20 mm,Standard,Plain,,R 4.60
|
||||
SM2NUT93418,M18 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,18 mm,Standard,Plain,,R 3.98
|
||||
SM2NUT93416,M16 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,16 mm,Standard,Plain,,R 2.23
|
||||
SM2NUT93414,M14 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,14 mm,Standard,Plain,,R 1.80
|
||||
SM2NUT93412,M12 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,12 mm,Standard,Plain,,R 1.15
|
||||
SM2NUT93410,M10 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,10 mm,Standard,Plain,,R 0.77
|
||||
SM2NUT93408,M8 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,8 mm,Standard,Plain,,R 0.39
|
||||
SM2NUT93406,M6 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,6 mm,Standard,Plain,,R 0.17
|
||||
SM2NUT93405,M5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,5 mm,Standard,Plain,,R 0.11
|
||||
SM2NUT93404,M4 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,4 mm,Standard,Plain,,R 0.08
|
||||
SM2NUT934035,M3.5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,3.5 mm,Standard,Plain,,R 0.22
|
||||
SM2NUT93403,M3 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,3 mm,Standard,Plain,,R 0.06
|
||||
SM2NUT934025,M2.5 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,2.5 mm,Standard,Plain,,R 0.09
|
||||
SM2NUT93402,M2 NUT A2 PLN,Hex Nut DIN934,Stainless Steel,A2,2 mm,Standard,Plain,,R 0.09
|
||||
|
131
scripts/nss-scrape-bundle.js
Normal file
131
scripts/nss-scrape-bundle.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Run in the browser console on nssfasteners.co.za after login.
|
||||
// Fetches all Grade 8 (101 items) + A2 (21 items) hex nut DIN934 product pages,
|
||||
// parses the hidden unit prices out of the HTML, builds PosDescription strings,
|
||||
// and triggers a CSV download.
|
||||
|
||||
(async () => {
|
||||
const BASE = 'https://nssfasteners.co.za/nuts/hex-nuts/hex-nut-din934.html';
|
||||
const FILTERS = [
|
||||
{ grade: '8', gradeId: '771', expectedCount: 101 },
|
||||
{ grade: 'A2', gradeId: '773', expectedCount: 21 },
|
||||
];
|
||||
|
||||
// ---- helpers ----
|
||||
const fetchHtml = async (url) => {
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
return await res.text();
|
||||
};
|
||||
|
||||
const parsePage = (html) => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const items = Array.from(doc.querySelectorAll('.product-item'));
|
||||
return items.map(item => {
|
||||
const titleEl = item.querySelector('.items-center.justify-center.text-primary');
|
||||
const title = titleEl ? titleEl.textContent.trim() : '';
|
||||
const sku = title.includes(' - ') ? title.split(' - ').pop().trim() : '';
|
||||
const linkEl = item.querySelector('a.product-item-photo, a.product.photo');
|
||||
const url = linkEl ? linkEl.getAttribute('href') : '';
|
||||
|
||||
// pull the description block (Material / Diameter / Thread / Grade / Finish / Pack)
|
||||
const block = (item.textContent || '').replace(/\s+/g, ' ');
|
||||
const get = (label) => {
|
||||
const m = block.match(new RegExp(label + '\\s*:?\\s*([^\\s].*?)(?=\\s+(?:Diameter|Thread|Grade|Finish|Sold|Metric|Hi-Tensile|Stainless|Mild|Brass|$))', 'i'));
|
||||
return m ? m[1].trim() : '';
|
||||
};
|
||||
|
||||
const material = /Hi-Tensile/i.test(block) ? 'Hi-Tensile'
|
||||
: /Stainless Steel/i.test(block) ? 'Stainless Steel'
|
||||
: /Mild Steel/i.test(block) ? 'Mild Steel'
|
||||
: /Brass/i.test(block) ? 'Brass'
|
||||
: '';
|
||||
const diameter = get('Diameter');
|
||||
const thread = get('Thread');
|
||||
const grade = get('Grade');
|
||||
const finish = get('Finish');
|
||||
const pack = /Sold as a Single Piece/i.test(block) ? 'Single Piece'
|
||||
: /Sold as a Box/i.test(block) ? 'Box'
|
||||
: /Sold as a Pack/i.test(block) ? 'Pack'
|
||||
: '';
|
||||
|
||||
// Unit price is in the HTML but hidden with display:none
|
||||
const unitPriceEl = item.querySelector('.unit-price');
|
||||
const priceMatch = unitPriceEl ? unitPriceEl.textContent.match(/R\s*([\d.]+)/) : null;
|
||||
const unitPrice = priceMatch ? priceMatch[1] : '';
|
||||
|
||||
// Description is the parent product type (e.g. "Hex Nut DIN934")
|
||||
const desc = title.includes(' - ') ? title.split(' - ')[0].trim() : '';
|
||||
|
||||
// Build PosDescription like: "M39 NUT 8 PLN"
|
||||
// diameter -> "M{value}", normalised
|
||||
const sizeMatch = diameter.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
||||
const size = sizeMatch ? 'M' + sizeMatch[1].replace(/\.0$/, '') : diameter;
|
||||
const gradeShort = grade === 'Stainless' ? '' : grade; // "8" or "A2"
|
||||
const posDescription = [size, 'NUT', gradeShort, finish.toUpperCase() === 'PLAIN' ? 'PLN' : finish.toUpperCase()]
|
||||
.filter(Boolean).join(' ');
|
||||
|
||||
return {
|
||||
sku,
|
||||
posDescription,
|
||||
description: desc,
|
||||
material,
|
||||
grade,
|
||||
diameter,
|
||||
thread,
|
||||
finish,
|
||||
pack,
|
||||
unitPrice: unitPrice ? 'R ' + unitPrice : '',
|
||||
url,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const allRows = [];
|
||||
for (const f of FILTERS) {
|
||||
// First page
|
||||
const firstUrl = `${BASE}?gradematerial=${f.gradeId}`;
|
||||
const firstHtml = await fetchHtml(firstUrl);
|
||||
const firstRows = parsePage(firstHtml);
|
||||
allRows.push(...firstRows);
|
||||
|
||||
// Discover total page count from toolbar
|
||||
const doc = new DOMParser().parseFromString(firstHtml, 'text/html');
|
||||
const m = (doc.querySelector('.toolbar-amount')?.textContent || '').match(/of\s+(\d+)/);
|
||||
const total = m ? parseInt(m[1], 10) : f.expectedCount;
|
||||
const pages = Math.ceil(total / 10);
|
||||
console.log(`Grade ${f.grade}: ${firstRows.length} items on page 1, ${pages} pages total, expected ${total}`);
|
||||
|
||||
// Remaining pages
|
||||
for (let p = 2; p <= pages; p++) {
|
||||
const html = await fetchHtml(`${BASE}?gradematerial=${f.gradeId}&p=${p}`);
|
||||
const rows = parsePage(html);
|
||||
allRows.push(...rows);
|
||||
console.log(`Grade ${f.grade}: page ${p}/${pages} -> ${rows.length} items`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`TOTAL ROWS: ${allRows.length}`);
|
||||
|
||||
// Build CSV
|
||||
const headers = ['SKU','PosDescription','Description','Material','Grade','Diameter','Thread','Finish','Pack','UnitPrice','SourceURL'];
|
||||
const esc = (v) => {
|
||||
const s = (v ?? '').toString().replace(/"/g, '""');
|
||||
return /[",\n]/.test(s) ? `"${s}"` : s;
|
||||
};
|
||||
const csv = [headers.join(',')]
|
||||
.concat(allRows.map(r => headers.map(h => esc(r[h.toLowerCase()])).join(',')))
|
||||
.join('\n');
|
||||
|
||||
// Trigger download
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'nss-hex-nut-din934-grade8-a2.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return { count: allRows.length, sample: allRows.slice(0, 3) };
|
||||
})();
|
||||
273
scripts/recent-stock-movements.ts
Normal file
273
scripts/recent-stock-movements.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* One-off diagnostic: list every `stock_items` row that was created
|
||||
* or updated since 2026-09-14T22:00:00Z (~36h ago, "yesterday +
|
||||
* today" Johannesburg time), and highlight the ones still missing
|
||||
* a `cost_price` or `selling_price`.
|
||||
*
|
||||
* Why this exists
|
||||
* ───────────────
|
||||
* The operator noticed "new" items on the POS are showing without
|
||||
* prices. The recent 80% markup commit (Mon night) patched a lot
|
||||
* of rows in-place via `updated`, so the "updated in window" set
|
||||
* conflates two populations:
|
||||
* (a) genuinely-new items added recently, and
|
||||
* (b) pre-existing rows whose `selling_price` / `markup` was just
|
||||
* patched by the markup run.
|
||||
* This script splits those two apart in the summary block and
|
||||
* then prints a full table of every in-window row that STILL has
|
||||
* `cost_price <= 0` OR `selling_price <= 0` — the actual gap the
|
||||
* POS is exposing.
|
||||
*
|
||||
* Read-only — never writes to PB.
|
||||
*
|
||||
* Run with:
|
||||
* npx tsx scripts/recent-stock-movements.ts
|
||||
*
|
||||
* Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL,
|
||||
* PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD.
|
||||
*/
|
||||
|
||||
import "./_env"; // side-effect import: loads .env.local before anything else
|
||||
|
||||
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lower bound of the "recently moved" window. Hard-coded to the
|
||||
* Johannesburg "yesterday + today" cutoff the operator asked for
|
||||
* (2026-09-14 22:00 UTC == 2026-09-15 00:00 SAST). Edit here if
|
||||
* the operator wants a different window.
|
||||
*/
|
||||
const WINDOW_START_ISO = "2026-09-14T22:00:00.000Z";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Single `stock_items` row shape — the fields we read for the
|
||||
* report. `expand.supplier` is the joined supplier name. */
|
||||
type StockRow = {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
current_qty: number;
|
||||
cost_price?: number | null;
|
||||
selling_price?: number | null;
|
||||
markup?: number | null;
|
||||
created: string;
|
||||
updated: string;
|
||||
supplier?: string;
|
||||
expand?: {
|
||||
supplier?: { id?: string; suppliername?: string };
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Formatting ────────────────────────────────────────────────────────
|
||||
|
||||
/** Format a number as R X.XX, or "—" for missing/non-finite values. */
|
||||
function fmtMoney(n: number | null | undefined): string {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return "—";
|
||||
return `R ${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Format a markup percent. PB stores markup as a plain number
|
||||
* (e.g. 80 means 80%); render with a trailing "%" when present. */
|
||||
function fmtMarkup(n: number | null | undefined): string {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return "—";
|
||||
return `${n}%`;
|
||||
}
|
||||
|
||||
/** Truncate an ISO timestamp to "YYYY-MM-DD HH:MM" in UTC. PB's
|
||||
* autodate strings are already UTC, so no conversion is needed —
|
||||
* we just slice the trailing ":00.000Z" off to keep the table
|
||||
* compact. */
|
||||
function fmtDateTime(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` +
|
||||
` ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Render the gap table. Numeric columns are right-aligned via the
|
||||
* `---:` colon convention so the operator can scan decimals. */
|
||||
function renderGapTable(rows: StockRow[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
"| SKU | Name | Supplier | Cost (R) | Selling (R) | Markup | Qty | Created | Updated |",
|
||||
);
|
||||
lines.push(
|
||||
"| --- | --- | --- | ---: | ---: | ---: | ---: | --- | --- |",
|
||||
);
|
||||
|
||||
for (const r of rows) {
|
||||
const sku = r.sku || "(no sku)";
|
||||
const name = (r.name || "(no name)").slice(0, 60); // keep names from blowing up the table
|
||||
const supplier = r.expand?.supplier?.suppliername?.trim() || "—";
|
||||
const qty = Number.isFinite(r.current_qty) ? r.current_qty : 0;
|
||||
|
||||
lines.push(
|
||||
`| ${sku} | ${name} | ${supplier} | ${fmtMoney(r.cost_price)} | ${fmtMoney(r.selling_price)} | ${fmtMarkup(r.markup)} | ${qty} | ${fmtDateTime(r.created)} | ${fmtDateTime(r.updated)} |`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── PB plumbing ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pull every `stock_items` row whose `created` or `updated` is on
|
||||
* or after `WINDOW_START_ISO`. Both fields are autodate strings in
|
||||
* PB and lexicographically compare correctly as ISO-8601 UTC, so
|
||||
* the `>=` operator works without parsing.
|
||||
*
|
||||
* The `~ ` operator is NOT used here — PB's `~` is `contains`,
|
||||
* not `regex`, and it's irrelevant for an exact-bound comparison.
|
||||
*
|
||||
* We do NOT pass a `fields` projection: passing `fields=` together
|
||||
* with `expand=supplier` causes PB to drop the `expand` payload
|
||||
* (see the comment in `list-irda-stock.ts`). Pulling the full row
|
||||
* is fine here — the dataset is bounded by the ~36h window, not
|
||||
* by the whole catalogue.
|
||||
*
|
||||
* Sort: `-updated` so the most-recently-moved rows float to the
|
||||
* top. The client-side sub-sort for the gap table also uses
|
||||
* `updated DESC` (and then `created DESC`) so an item patched
|
||||
* moments ago reads before one patched yesterday morning.
|
||||
*/
|
||||
async function listRecentStockItems(windowStartIso: string): Promise<StockRow[]> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const rows = await pb.collection("stock_items").getFullList<StockRow>({
|
||||
filter: `created >= "${windowStartIso}" || updated >= "${windowStartIso}"`,
|
||||
expand: "supplier",
|
||||
sort: "-updated",
|
||||
batch: 500,
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const startTs = Date.now();
|
||||
const windowStartIso = WINDOW_START_ISO;
|
||||
const windowStart = new Date(windowStartIso);
|
||||
|
||||
console.log(
|
||||
`=== Recently-moved stock items (since ${windowStartIso}) ===\n`,
|
||||
);
|
||||
|
||||
const allRows = await listRecentStockItems(windowStartIso);
|
||||
const totalInWindow = allRows.length;
|
||||
|
||||
// Split the in-window set into "newly created" vs "only patched"
|
||||
// by comparing `created` against the window start. An item whose
|
||||
// `created` is BEFORE the window but whose `updated` is IN the
|
||||
// window was patched (the Monday-night 80% markup run hit a lot
|
||||
// of these). An item whose `created` is IN the window is
|
||||
// genuinely new.
|
||||
const isInWindow = (iso: string): boolean => {
|
||||
if (!iso) return false;
|
||||
const d = new Date(iso);
|
||||
return d.getTime() >= windowStart.getTime();
|
||||
};
|
||||
|
||||
const newlyCreated = allRows.filter((r) => isInWindow(r.created));
|
||||
const updatedOnly = allRows.filter(
|
||||
(r) => !isInWindow(r.created) && isInWindow(r.updated),
|
||||
);
|
||||
|
||||
// The gap = cost_price <= 0 OR selling_price <= 0. PB's autodate
|
||||
// for missing numerics resolves to 0 / null on the typed shape;
|
||||
// we treat anything `!(x > 0)` as missing, matching the
|
||||
// convention used in `apply-default-markup.ts` and
|
||||
// `list-irda-stock.ts`.
|
||||
const isGapRow = (r: StockRow): boolean => {
|
||||
const cost = Number(r.cost_price ?? 0);
|
||||
const sell = Number(r.selling_price ?? 0);
|
||||
return !(cost > 0) || !(sell > 0);
|
||||
};
|
||||
|
||||
const gapRows = allRows
|
||||
.filter(isGapRow)
|
||||
// Sort newest-created/updated first within the gap. Compare on
|
||||
// the max(created, updated) so a row created last week but
|
||||
// patched yesterday sorts before one created last week and
|
||||
// never touched again. Falls back to created if both are
|
||||
// missing.
|
||||
.toSorted((a, b) => {
|
||||
const aMax = Math.max(
|
||||
new Date(a.created).getTime() || 0,
|
||||
new Date(a.updated).getTime() || 0,
|
||||
);
|
||||
const bMax = Math.max(
|
||||
new Date(b.created).getTime() || 0,
|
||||
new Date(b.updated).getTime() || 0,
|
||||
);
|
||||
return bMax - aMax;
|
||||
});
|
||||
|
||||
const missingCost = gapRows.filter((r) => !(Number(r.cost_price ?? 0) > 0));
|
||||
const missingSelling = gapRows.filter(
|
||||
(r) => !(Number(r.selling_price ?? 0) > 0),
|
||||
);
|
||||
// Rows that have BOTH prices populated (the "happy path"
|
||||
// population — what the markup commit was aiming for).
|
||||
const fullyPriced = allRows.filter(
|
||||
(r) =>
|
||||
Number(r.cost_price ?? 0) > 0 && Number(r.selling_price ?? 0) > 0,
|
||||
);
|
||||
|
||||
// Summary block. The operator specifically asked us to be loud
|
||||
// about `Items missing cost_price` — if it's zero we say so.
|
||||
console.log("Summary:");
|
||||
console.log(` Total items changed in window: ${totalInWindow}`);
|
||||
console.log(` Items still missing cost_price: ${missingCost.length}${missingCost.length === 0 ? " ← no missing-cost rows in window; the gap is purely on the SELL side" : ""}`);
|
||||
console.log(` Items still missing selling_price: ${missingSelling.length}`);
|
||||
console.log(` Items with both fields populated: ${fullyPriced.length}`);
|
||||
console.log(` Items created fresh (not just patched): ${newlyCreated.length}`);
|
||||
console.log(` Items updated only (cost/markup patch): ${updatedOnly.length}`);
|
||||
|
||||
console.log(
|
||||
`\n=== Gap rows: cost_price <= 0 OR selling_price <= 0 (${gapRows.length} row${gapRows.length === 1 ? "" : "s"}) ===\n`,
|
||||
);
|
||||
if (gapRows.length === 0) {
|
||||
console.log(
|
||||
" (none — every in-window row has both cost_price and selling_price populated)",
|
||||
);
|
||||
} else {
|
||||
console.log(renderGapTable(gapRows));
|
||||
}
|
||||
|
||||
// A small pattern-recognition addendum: tally supplier frequency
|
||||
// and qty=0 rate inside the gap, so the operator can eyeball
|
||||
// whether the gap is "one supplier forgot to ship pricing" or
|
||||
// "every supplier has a few stragglers".
|
||||
if (gapRows.length > 0) {
|
||||
const supplierCounts = new Map<string, number>();
|
||||
let zeroQty = 0;
|
||||
for (const r of gapRows) {
|
||||
const name = r.expand?.supplier?.suppliername?.trim() || "(no supplier)";
|
||||
supplierCounts.set(name, (supplierCounts.get(name) ?? 0) + 1);
|
||||
if (!(Number(r.current_qty) > 0)) zeroQty += 1;
|
||||
}
|
||||
const supplierLines = [...supplierCounts.entries()]
|
||||
.toSorted((a, b) => b[1] - a[1])
|
||||
.map(([name, n]) => ` ${n}× ${name}`);
|
||||
|
||||
console.log(`\nGap breakdown by supplier:`);
|
||||
console.log(supplierLines.join("\n"));
|
||||
console.log(`\nGap rows with current_qty = 0: ${zeroQty} / ${gapRows.length}`);
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTs) / 1000).toFixed(1);
|
||||
console.log(`\n duration: ${duration}s`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("FATAL:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { assertAgentAuth } from "@/lib/server/agent-auth";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import { applyDefaultMarkupToPatch } from "@/lib/server/stock-default-markup-service";
|
||||
import type { StockItem } from "@/lib/types";
|
||||
|
||||
/**
|
||||
|
|
@ -55,7 +56,18 @@ export async function POST(req: NextRequest) {
|
|||
const hasMarkup =
|
||||
typeof markupRaw === "number" && Number.isFinite(markupRaw);
|
||||
|
||||
const item = await pb.collection("stock_items").create<StockItem>({
|
||||
// Build the patch in two stages:
|
||||
// 1. The operator-supplied fields (sku, name, opening qty,
|
||||
// pricing, etc).
|
||||
// 2. `applyDefaultMarkupToPatch` runs over the patch — if
|
||||
// `costPrice` is positive and the operator did not also
|
||||
// supply `sellingPrice` / `markup`, the helper fills
|
||||
// those in at 80% (rounded to R cents) and bootstraps
|
||||
// `cost_history` with a single entry. Idempotent: the
|
||||
// helper never overwrites an operator-set price. Pass
|
||||
// `undefined` for the existing row (no row yet — this
|
||||
// is the create path).
|
||||
const rawPatch: Record<string, unknown> = {
|
||||
sku: (body.sku || "").trim(),
|
||||
name: body.name.trim(),
|
||||
description: body.description || "",
|
||||
|
|
@ -67,21 +79,15 @@ export async function POST(req: NextRequest) {
|
|||
is_active: true,
|
||||
notes: body.notes || "",
|
||||
...(hasCostPrice
|
||||
? {
|
||||
cost_price: costPriceRaw,
|
||||
cost_history: [
|
||||
{
|
||||
cost_price: costPriceRaw,
|
||||
quantity: 0,
|
||||
purchased_at: new Date().toISOString(),
|
||||
purchase_id: "",
|
||||
},
|
||||
],
|
||||
}
|
||||
? { cost_price: costPriceRaw, cost_history: [] }
|
||||
: { cost_history: [] }),
|
||||
...(hasSellingPrice ? { selling_price: sellingPriceRaw } : {}),
|
||||
...(hasMarkup ? { markup: markupRaw } : {}),
|
||||
});
|
||||
};
|
||||
if (hasSellingPrice) rawPatch.selling_price = sellingPriceRaw;
|
||||
if (hasMarkup) rawPatch.markup = markupRaw;
|
||||
|
||||
const item = await pb.collection("stock_items").create<StockItem>(
|
||||
applyDefaultMarkupToPatch(rawPatch, undefined) as Partial<StockItem>,
|
||||
);
|
||||
|
||||
// Opening-balance ledger row — only when the operator seeded a
|
||||
// non-zero qty. Reason "adjustment" is the closest fit: it's a
|
||||
|
|
|
|||
|
|
@ -372,6 +372,10 @@ describe("/api/v1/stock/items", () => {
|
|||
purchase_id: "",
|
||||
});
|
||||
expect(typeof itemCall.cost_history[0].purchased_at).toBe("string");
|
||||
// Auto-markup fires (cost > 0, no operator-set selling/markup):
|
||||
// 12.5 * 1.80 = 22.5.
|
||||
expect(itemCall.selling_price).toBe(22.5);
|
||||
expect(itemCall.markup).toBe(80);
|
||||
});
|
||||
|
||||
it("writes selling_price and markup through to the stock_items row when supplied", async () => {
|
||||
|
|
@ -405,6 +409,9 @@ describe("/api/v1/stock/items", () => {
|
|||
const itemCall = create.mock.calls[0][0];
|
||||
expect(itemCall.cost_price).toBeUndefined();
|
||||
expect(itemCall.cost_history).toEqual([]);
|
||||
// No cost → no auto-markup.
|
||||
expect(itemCall.selling_price).toBeUndefined();
|
||||
expect(itemCall.markup).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -223,6 +223,9 @@ export async function bookStockFromInvoice(
|
|||
.getOne(stockItemId)) as unknown as {
|
||||
current_qty?: number;
|
||||
cost_history?: unknown;
|
||||
cost_price?: number;
|
||||
selling_price?: number;
|
||||
markup?: number;
|
||||
};
|
||||
await pb.collection(STOCK_ITEMS).update(stockItemId, {
|
||||
current_qty: Number(stockItem.current_qty ?? 0) + qtyReceived,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
import type { StockCostHistoryEntry } from "@/lib/types";
|
||||
import { applyDefaultMarkupToPatch } from "./stock-default-markup-service";
|
||||
|
||||
/**
|
||||
* Build the stock-item cost patch written when a purchase line is received.
|
||||
* The purchase line remains the immutable source record; this keeps a compact
|
||||
* operational current cost plus an append-only history on the stock item.
|
||||
*
|
||||
* Also opportunistically seeds `selling_price` / `markup` at the
|
||||
* default 80% rate when the row doesn't already have them set —
|
||||
* delegates the idempotency check to
|
||||
* `applyDefaultMarkupToPatch` so the operator CLI backfill and
|
||||
* this write-path stay in lockstep. The `cost_history` patch
|
||||
* (append + entry) is built here and is committed to the patch
|
||||
* BEFORE the helper runs, so the helper's "patch already has a
|
||||
* non-empty history" rule preserves it.
|
||||
*/
|
||||
export function buildStockReceiptCostPatch(
|
||||
stockItem: { cost_history?: unknown },
|
||||
stockItem: {
|
||||
cost_history?: unknown;
|
||||
cost_price?: number | null;
|
||||
selling_price?: number | null;
|
||||
markup?: number | null;
|
||||
},
|
||||
input: {
|
||||
costPrice: number;
|
||||
quantity: number;
|
||||
|
|
@ -14,7 +29,12 @@ export function buildStockReceiptCostPatch(
|
|||
supplierId?: string;
|
||||
purchaseId: string;
|
||||
},
|
||||
): { cost_price: number; cost_history: StockCostHistoryEntry[] } {
|
||||
): {
|
||||
cost_price: number;
|
||||
cost_history: StockCostHistoryEntry[];
|
||||
selling_price?: number;
|
||||
markup?: number;
|
||||
} {
|
||||
const history = Array.isArray(stockItem.cost_history)
|
||||
? stockItem.cost_history.filter(
|
||||
(entry): entry is StockCostHistoryEntry =>
|
||||
|
|
@ -32,8 +52,34 @@ export function buildStockReceiptCostPatch(
|
|||
...(input.supplierId ? { supplier_id: input.supplierId } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
// Base patch — committed by the caller. We run it through the
|
||||
// helper LAST so the helper can opportunistically add
|
||||
// `selling_price` / `markup` (without touching our `cost_history`
|
||||
// append). The helper's "patch owns history" guard means the
|
||||
// appended entry is never overwritten.
|
||||
const basePatch: Record<string, unknown> = {
|
||||
cost_price: input.costPrice,
|
||||
cost_history: [...history, entry],
|
||||
};
|
||||
const finalised = applyDefaultMarkupToPatch(
|
||||
basePatch,
|
||||
{
|
||||
cost_price: stockItem.cost_price,
|
||||
selling_price: stockItem.selling_price,
|
||||
markup: stockItem.markup,
|
||||
cost_history: stockItem.cost_history,
|
||||
},
|
||||
input.receivedAt,
|
||||
);
|
||||
|
||||
return {
|
||||
cost_price: input.costPrice,
|
||||
cost_history: finalised.cost_history as StockCostHistoryEntry[],
|
||||
...(finalised.selling_price !== undefined
|
||||
? { selling_price: finalised.selling_price as number }
|
||||
: {}),
|
||||
...(finalised.markup !== undefined
|
||||
? { markup: finalised.markup as number }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
291
src/lib/server/stock-default-markup-service.test.ts
Normal file
291
src/lib/server/stock-default-markup-service.test.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
/**
|
||||
* Tests for the pure default-markup helper.
|
||||
*
|
||||
* Mirrors the test style of `scripts/apply-default-markup.test.ts`:
|
||||
* no PB stub needed (the helper is pure), exported `applyDefaultMarkupToPatch`
|
||||
* + `round2` are the surface under test. Each test pins one of the
|
||||
* helper's documented rules — keep them in lockstep with the
|
||||
* JSDoc bullets in `stock-default-markup-service.ts`.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
applyDefaultMarkupToPatch,
|
||||
round2,
|
||||
DEFAULT_MARKUP_PERCENT,
|
||||
DEFAULT_MARKUP_MULTIPLIER,
|
||||
DEFAULT_COST_HISTORY_PURCHASE_ID,
|
||||
} from "./stock-default-markup-service";
|
||||
|
||||
// A fixed clock so the optional `cost_history` seed entry's
|
||||
// `purchased_at` is deterministic across runs.
|
||||
const NOW = "2026-09-16T08:50:00.000Z";
|
||||
|
||||
describe("round2", () => {
|
||||
it("rounds 117.39 * 1.80 to two decimals (211.302 → 211.30)", () => {
|
||||
expect(round2(117.39 * DEFAULT_MARKUP_MULTIPLIER)).toBe(211.30);
|
||||
});
|
||||
|
||||
it("rounds 33.33 * 1.80 (59.994 → 59.99)", () => {
|
||||
expect(round2(33.33 * DEFAULT_MARKUP_MULTIPLIER)).toBe(59.99);
|
||||
});
|
||||
|
||||
it("preserves a value that's already at 2dp", () => {
|
||||
expect(round2(50.55 * DEFAULT_MARKUP_MULTIPLIER)).toBe(90.99);
|
||||
});
|
||||
|
||||
it("returns 0 for non-finite input", () => {
|
||||
expect(round2(Number.NaN)).toBe(0);
|
||||
expect(round2(Number.POSITIVE_INFINITY)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDefaultMarkupToPatch", () => {
|
||||
// 1. Guard: cost_price = 0 in patch → helper does nothing.
|
||||
it("returns the patch unchanged when cost_price in the patch is 0", () => {
|
||||
const out = applyDefaultMarkupToPatch({ cost_price: 0 }, undefined, NOW);
|
||||
expect(out).toEqual({ cost_price: 0 });
|
||||
expect(out.selling_price).toBeUndefined();
|
||||
expect(out.markup).toBeUndefined();
|
||||
});
|
||||
|
||||
// 2. Happy path: cost > 0, no existing row, no selling/markup in patch.
|
||||
it("populates selling_price and markup when cost_price is positive and selling/markup are unset", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 100 },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(out.selling_price).toBe(180);
|
||||
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
||||
expect(out.cost_price).toBe(100);
|
||||
});
|
||||
|
||||
// 3. Idempotency — existing row has selling_price > 0.
|
||||
it("does NOT overwrite selling_price when the existing row already has a non-zero selling_price", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 100 },
|
||||
{ cost_price: 100, selling_price: 150, markup: 0 },
|
||||
NOW,
|
||||
);
|
||||
expect(out.selling_price).toBeUndefined(); // helper never writes the field
|
||||
expect(out.markup).toBeUndefined(); // and never the markup either (selling was already set)
|
||||
});
|
||||
|
||||
// 4. Idempotency — existing row has markup > 0.
|
||||
it("does NOT overwrite markup when the existing row already has a non-zero markup", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 100 },
|
||||
{ cost_price: 100, selling_price: 0, markup: 50 },
|
||||
NOW,
|
||||
);
|
||||
expect(out.markup).toBeUndefined();
|
||||
expect(out.selling_price).toBeUndefined();
|
||||
});
|
||||
|
||||
// 5. Idempotency — patch already sets selling_price explicitly.
|
||||
it("does NOT overwrite selling_price when the patch sets it to a non-zero value", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 100, selling_price: 222 },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(out.selling_price).toBe(222); // patch's value, untouched
|
||||
expect(out.markup).toBeUndefined(); // operator chose the shelf price directly; markup left for them
|
||||
});
|
||||
|
||||
// 5b. Idempotency — patch sets markup explicitly (and selling_price is zero).
|
||||
it("does NOT add selling_price when the patch sets markup to a non-zero value", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 100, markup: 25 },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(out.markup).toBe(25); // patch's value, untouched
|
||||
expect(out.selling_price).toBeUndefined();
|
||||
});
|
||||
|
||||
// 6. cost_history bootstrap — patch sets cost_price > 0, existing row has no history.
|
||||
it("seeds cost_history with a single entry when cost_price is positive and the existing row has no history", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 18.5 },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(Array.isArray(out.cost_history)).toBe(true);
|
||||
expect(out.cost_history).toHaveLength(1);
|
||||
expect(out.cost_history).toEqual([
|
||||
{
|
||||
cost_price: 18.5,
|
||||
quantity: 0,
|
||||
purchased_at: NOW,
|
||||
purchase_id: DEFAULT_COST_HISTORY_PURCHASE_ID,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds cost_history using the existing row's cost when the patch only adds markup (no patch.cost_price)", () => {
|
||||
// Edge: the patch is just `{ markup: 50 }` on a row that already
|
||||
// has cost_price > 0 and a populated history. The history
|
||||
// preservation rule fires (don't overwrite history). The cost
|
||||
// we record in the seed would be the existing row's value.
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ markup: 50 },
|
||||
{ cost_price: 25, selling_price: 0, markup: 0, cost_history: [] },
|
||||
NOW,
|
||||
);
|
||||
// Patch set markup explicitly → helper doesn't auto-set markup.
|
||||
expect(out.markup).toBe(50);
|
||||
// Patch did NOT set cost_price, so the cost_history bootstrap
|
||||
// is a no-op (rule: only seed when the patch is the source of
|
||||
// the cost).
|
||||
expect(out.cost_history).toBeUndefined();
|
||||
});
|
||||
|
||||
// 7. Existing history preserved.
|
||||
it("does NOT seed cost_history when the existing row already has history", () => {
|
||||
const existingHistory = [
|
||||
{
|
||||
cost_price: 17.0,
|
||||
quantity: 5,
|
||||
purchased_at: "2026-08-01T08:00:00.000Z",
|
||||
purchase_id: "PO26080101STK",
|
||||
},
|
||||
];
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 18.5 },
|
||||
{ cost_price: 18.5, selling_price: 0, markup: 0, cost_history: existingHistory },
|
||||
NOW,
|
||||
);
|
||||
expect(out.cost_history).toBeUndefined(); // helper didn't touch it
|
||||
});
|
||||
|
||||
// 7b. cost_history=null on existing row → treated as no history → seed fires.
|
||||
it("seeds cost_history when existing.cost_history is null", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 18.5 },
|
||||
{ cost_price: 18.5, selling_price: 0, markup: 0, cost_history: null },
|
||||
NOW,
|
||||
);
|
||||
expect(Array.isArray(out.cost_history)).toBe(true);
|
||||
expect(out.cost_history).toHaveLength(1);
|
||||
});
|
||||
|
||||
// 7c. Patch already has non-empty cost_history → helper doesn't seed.
|
||||
it("does NOT seed cost_history when the patch already has a non-empty history (patch wins)", () => {
|
||||
// The booking flow appends to existing history on the patch
|
||||
// before calling the helper. The helper must not overwrite
|
||||
// that — the patch's array is what we're committing to.
|
||||
const appendedEntry = {
|
||||
cost_price: 19.0,
|
||||
quantity: 10,
|
||||
purchased_at: "2026-09-01T08:00:00.000Z",
|
||||
purchase_id: "PO26090101STK",
|
||||
};
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 18.5, cost_history: [appendedEntry] },
|
||||
{ cost_price: 0, selling_price: 0, markup: 0, cost_history: [] },
|
||||
NOW,
|
||||
);
|
||||
expect(out.cost_history).toEqual([appendedEntry]);
|
||||
});
|
||||
|
||||
// 8. Rounding: cost_price = 117.39 → selling_price = 211.30.
|
||||
it("rounds 117.39 → 211.30 (the exact case the operator script pins)", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 117.39 },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(out.selling_price).toBe(211.30);
|
||||
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
||||
});
|
||||
|
||||
// 9. Edge: cost_price = 7.80 → selling_price = 14.04.
|
||||
it("rounds 7.80 → 14.04", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 7.80 },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(out.selling_price).toBe(14.04);
|
||||
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
||||
});
|
||||
|
||||
// 10. Edge: very small cost like 0.93 → selling_price = 1.67.
|
||||
it("rounds 0.93 → 1.67 (cents-rounding sanity check)", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 0.93 },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(out.selling_price).toBe(1.67);
|
||||
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
||||
});
|
||||
|
||||
// Bonus: a patch that includes unrelated fields keeps them intact.
|
||||
it("leaves unrelated fields in the patch untouched", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 50, name: "Widget", sku: "WID-1" },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(out.name).toBe("Widget");
|
||||
expect(out.sku).toBe("WID-1");
|
||||
expect(out.selling_price).toBe(90); // 50 * 1.80
|
||||
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
||||
});
|
||||
|
||||
// Bonus: existing cost_history is an empty array → seed fires.
|
||||
it("seeds cost_history when existing.cost_history is an empty array", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 9.99 },
|
||||
{ cost_price: 9.99, selling_price: 0, markup: 0, cost_history: [] },
|
||||
NOW,
|
||||
);
|
||||
const history = out.cost_history as unknown[];
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history).toHaveLength(1);
|
||||
expect((history[0] as { cost_price: number }).cost_price).toBe(9.99);
|
||||
});
|
||||
|
||||
// Bonus: when no `now` is supplied, the helper still writes a
|
||||
// valid ISO `purchased_at`. We don't pin a specific value
|
||||
// (it's `Date.now()`-driven), just the shape.
|
||||
it("generates a fresh ISO timestamp when `now` is omitted", () => {
|
||||
const out = applyDefaultMarkupToPatch({ cost_price: 10 }, undefined);
|
||||
const history = out.cost_history as unknown[];
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(typeof (history[0] as { purchased_at: unknown }).purchased_at).toBe(
|
||||
"string",
|
||||
);
|
||||
expect(
|
||||
new Date(
|
||||
(history[0] as { purchased_at: string }).purchased_at,
|
||||
).toString(),
|
||||
).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
// Bonus: when patch.cost_price is undefined and existing has no
|
||||
// cost, the helper short-circuits (no auto-markup, no history seed).
|
||||
it("does nothing when neither the patch nor the existing row have a cost", () => {
|
||||
const out = applyDefaultMarkupToPatch({}, undefined, NOW);
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
// Bonus: when the operator pre-sets selling_price AND markup on a
|
||||
// create, the helper still seeds cost_history (the audit log is
|
||||
// independent from the pricing block). The helper does NOT touch
|
||||
// selling_price / markup when the operator has set them.
|
||||
it("preserves operator-set selling_price/markup while still seeding cost_history", () => {
|
||||
const out = applyDefaultMarkupToPatch(
|
||||
{ cost_price: 100, selling_price: 175, markup: 75 },
|
||||
undefined,
|
||||
NOW,
|
||||
);
|
||||
expect(out.selling_price).toBe(175);
|
||||
expect(out.markup).toBe(75);
|
||||
expect(Array.isArray(out.cost_history)).toBe(true);
|
||||
expect(out.cost_history).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
220
src/lib/server/stock-default-markup-service.ts
Normal file
220
src/lib/server/stock-default-markup-service.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* Stock default-markup helper — auto-populate `selling_price` and
|
||||
* `markup` on any `stock_items` write that leaves those fields at
|
||||
* zero (or absent) while setting a positive `cost_price`.
|
||||
*
|
||||
* Why this exists
|
||||
* ───────────────
|
||||
* The catalogue has a long-standing gap: rows that have a known
|
||||
* `cost_price` but never had `selling_price` / `markup` set. These
|
||||
* items have a known input cost but no shelf price — the till
|
||||
* can't ring them up. The companion `scripts/apply-default-markup.ts`
|
||||
* CLI backfills those rows in bulk; this helper does the same
|
||||
* thing inline for every service-side write that creates or
|
||||
* updates a stock_item.
|
||||
*
|
||||
* The helper is pure (no PB I/O). Callers merge its output into
|
||||
* the patch they're about to send to PB; the helper decides
|
||||
* "should this patch also set selling_price + markup?" and
|
||||
* (optionally) bootstraps the `cost_history` array when a brand-
|
||||
* new row is being given a cost for the first time.
|
||||
*
|
||||
* Rules (mirrored exactly from `scripts/apply-default-markup.ts`
|
||||
* — keep the two in lockstep)
|
||||
* ─────
|
||||
* 1. Helper fires ONLY when the post-write `cost_price` is > 0.
|
||||
* Without a cost, there's nothing to base a markup on.
|
||||
* 2. Helper fires ONLY when the post-write `selling_price` is
|
||||
* zero or absent (no operator override of the shelf price).
|
||||
* 3. Helper fires ONLY when the post-write `markup` is zero or
|
||||
* absent (no operator override of the POS markup field).
|
||||
* 4. Never overwrite a non-zero `selling_price` or `markup`
|
||||
* anywhere — neither on the existing row, nor in the patch.
|
||||
* Idempotency is the whole point.
|
||||
* 5. Optionally seeds `cost_history` with a single entry when:
|
||||
* - the proposed patch sets `cost_price` to a positive value, AND
|
||||
* - the existing row has no `cost_history` at all (null/undefined
|
||||
* or empty array), AND
|
||||
* - the patch hasn't already set a non-empty `cost_history`
|
||||
* (the patch wins — if the caller is appending to an existing
|
||||
* history, don't overwrite it).
|
||||
* When the existing row already has history, preserve it (the
|
||||
* booking flow keeps appending to it). When the patch doesn't
|
||||
* set `cost_price` but the existing row has one, leave the
|
||||
* existing history alone.
|
||||
* 6. The amount we compare for the cost_history bootstrap uses
|
||||
* the post-write `cost_price` (i.e. the patch's value if
|
||||
* provided, otherwise the existing row's value).
|
||||
*
|
||||
* Defaults — keep these aligned with the operator script
|
||||
* ──────────────────────────────────────────────────────
|
||||
* `DEFAULT_MARKUP_PERCENT = 80` and `DEFAULT_MARKUP_MULTIPLIER
|
||||
* = 1.80`. Tweak only this one constant if the rate changes;
|
||||
* re-run the operator script with `--commit` to backfill any
|
||||
* existing rows that pre-date the change.
|
||||
*/
|
||||
import type { StockCostHistoryEntry } from "@/lib/types";
|
||||
|
||||
export const DEFAULT_MARKUP_PERCENT = 80;
|
||||
export const DEFAULT_MARKUP_MULTIPLIER = 1.80;
|
||||
|
||||
/**
|
||||
* Matches the seed shape used by `stock-quick-draft-service.ts` and
|
||||
* `/api/v1/stock/items/create` — `purchase_id` is the empty string
|
||||
* for "we knew the price when we typed it, no PO row to point at
|
||||
* yet"; the accountant's booking flow appends a second entry with a
|
||||
* real `purchase_id` when the supplier invoice is booked.
|
||||
*/
|
||||
export const DEFAULT_COST_HISTORY_PURCHASE_ID = "";
|
||||
|
||||
/**
|
||||
* The patch shape every caller hands us. Loose so callers can pass
|
||||
* an existing `Record<string, unknown>` (the same shape `pb.update`
|
||||
* and `pb.create` accept) without us forcing them to cast.
|
||||
*/
|
||||
export type StockPricingPatch = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The minimal existing-row shape the helper needs. Every field is
|
||||
* optional because (a) on a create there's no existing row at all
|
||||
* (pass `undefined`), and (b) PB's typed SDK may surface unset
|
||||
* numerics as `null` / `undefined`. The helper normalises those
|
||||
* to zero internally.
|
||||
*/
|
||||
export type ExistingStockPricingRow = {
|
||||
cost_price?: number | null | undefined;
|
||||
selling_price?: number | null | undefined;
|
||||
markup?: number | null | undefined;
|
||||
cost_history?: unknown;
|
||||
};
|
||||
|
||||
/** Round to 2 decimal places (R cents). Uses the rounding-to-even
|
||||
* behaviour of `Math.round` after a scale shift — sufficient for
|
||||
* currency display; PB stores the number as a plain float
|
||||
* underneath. Mirrors `apply-default-markup.ts`. */
|
||||
export function round2(n: number): number {
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the proposed write should auto-populate
|
||||
* `selling_price` and `markup`, and (optionally) seed
|
||||
* `cost_history` on a brand-new row that has never had a cost
|
||||
* history.
|
||||
*
|
||||
* Pure: no `Date.now()` at module level. The `now` argument lets
|
||||
* tests pin the `purchased_at` timestamp; production callers pass
|
||||
* `undefined` to get the current wall-clock.
|
||||
*
|
||||
* The function MUTATES the input patch (returning the same
|
||||
* reference for chaining convenience) — callers that want a
|
||||
* fresh object should pass a clone. We mutate (instead of
|
||||
* returning a new object) so the existing test pattern of
|
||||
* "inspect the captured patch" at the PB boundary keeps working
|
||||
* with one fewer copy step.
|
||||
*
|
||||
* @param patch The patch the caller is about to send to PB.
|
||||
* May already set `selling_price` / `markup` /
|
||||
* `cost_history` / `cost_price` — those values
|
||||
* are honoured. The function ONLY writes the
|
||||
* pricing fields when they're zero or absent.
|
||||
* @param existing The row being updated, or `undefined` on
|
||||
* create. Used for the idempotency check
|
||||
* (don't clobber an operator-set price) and
|
||||
* for the cost_history bootstrap (only seed
|
||||
* when the row has never had a history).
|
||||
* @param now ISO timestamp for the optional `cost_history`
|
||||
* seed entry's `purchased_at`. Defaults to
|
||||
* `new Date().toISOString()`.
|
||||
*/
|
||||
export function applyDefaultMarkupToPatch(
|
||||
patch: StockPricingPatch,
|
||||
existing?: ExistingStockPricingRow,
|
||||
now?: string,
|
||||
): StockPricingPatch {
|
||||
// 1. Resolve the post-write cost_price. The patch wins if it
|
||||
// sets the field; otherwise we use the existing row's
|
||||
// value. This is the "what cost are we committing to on
|
||||
// this write?" — the helper fires only when that's > 0.
|
||||
const patchedCostRaw = patch.cost_price;
|
||||
const existingCost = existing ? Number(existing.cost_price ?? 0) : 0;
|
||||
const postWriteCost =
|
||||
patchedCostRaw === undefined ? existingCost : Number(patchedCostRaw ?? 0);
|
||||
|
||||
// Guard 1: no cost → no markup. Nothing to base a price on.
|
||||
if (!(postWriteCost > 0)) {
|
||||
return patch;
|
||||
}
|
||||
|
||||
// 2. Resolve the post-write selling_price. Patch wins;
|
||||
// otherwise the existing row's value (or 0).
|
||||
const patchedSellingRaw = patch.selling_price;
|
||||
const existingSelling = existing ? Number(existing.selling_price ?? 0) : 0;
|
||||
const postWriteSelling =
|
||||
patchedSellingRaw === undefined
|
||||
? existingSelling
|
||||
: Number(patchedSellingRaw ?? 0);
|
||||
|
||||
// Resolve the post-write markup. Same pattern.
|
||||
const patchedMarkupRaw = patch.markup;
|
||||
const existingMarkup = existing ? Number(existing.markup ?? 0) : 0;
|
||||
const postWriteMarkup =
|
||||
patchedMarkupRaw === undefined
|
||||
? existingMarkup
|
||||
: Number(patchedMarkupRaw ?? 0);
|
||||
|
||||
// Guard 2 + 3: operator override on the shelf price OR the
|
||||
// POS markup field short-circuits the helper. We never
|
||||
// overwrite either — they're both authoritative the moment
|
||||
// they go non-zero.
|
||||
const patchSetsSellingExplicitly =
|
||||
patchedSellingRaw !== undefined && postWriteSelling > 0;
|
||||
const patchSetsMarkupExplicitly =
|
||||
patchedMarkupRaw !== undefined && postWriteMarkup > 0;
|
||||
const existingSellingAlreadySet = postWriteSelling > 0;
|
||||
const existingMarkupAlreadySet = postWriteMarkup > 0;
|
||||
|
||||
const shouldAutoMarkup =
|
||||
!patchSetsSellingExplicitly &&
|
||||
!patchSetsMarkupExplicitly &&
|
||||
!existingSellingAlreadySet &&
|
||||
!existingMarkupAlreadySet;
|
||||
|
||||
if (shouldAutoMarkup) {
|
||||
patch.selling_price = round2(postWriteCost * DEFAULT_MARKUP_MULTIPLIER);
|
||||
patch.markup = DEFAULT_MARKUP_PERCENT;
|
||||
}
|
||||
|
||||
// 3. Optionally seed cost_history. Only when:
|
||||
// - the PATCH is setting cost_price to a positive value
|
||||
// (not when we're just inheriting an existing cost), AND
|
||||
// - the existing row has no cost_history at all, AND
|
||||
// - the patch itself hasn't already set a non-empty
|
||||
// `cost_history` array (the patch wins — if the caller is
|
||||
// appending to an existing history, don't overwrite it).
|
||||
// The seed entry uses the post-write cost (so the patch's
|
||||
// value wins on a create, the existing row's wins on an
|
||||
// update). `purchase_id` is the empty string for the seed —
|
||||
// the booking flow will append a real `purchase_id` later.
|
||||
const existingHistory = existing?.cost_history;
|
||||
const hasExistingHistory =
|
||||
Array.isArray(existingHistory) && existingHistory.length > 0;
|
||||
const patchIsSettingCost =
|
||||
patchedCostRaw !== undefined && postWriteCost > 0;
|
||||
const patchOwnsHistory =
|
||||
Array.isArray(patch.cost_history) &&
|
||||
(patch.cost_history as unknown[]).length > 0;
|
||||
|
||||
if (patchIsSettingCost && !hasExistingHistory && !patchOwnsHistory) {
|
||||
const seedEntry: StockCostHistoryEntry = {
|
||||
cost_price: postWriteCost,
|
||||
quantity: 0,
|
||||
purchased_at: now ?? new Date().toISOString(),
|
||||
purchase_id: DEFAULT_COST_HISTORY_PURCHASE_ID,
|
||||
};
|
||||
patch.cost_history = [seedEntry];
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
|
@ -3,11 +3,13 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||
// --- PocketBase mock ---------------------------------------------------------
|
||||
//
|
||||
// Hoisted mock so the service module can call `pb.collection(...)`.
|
||||
// We seed two in-memory maps: `stock_items` rows and a small `suppliers`
|
||||
// lookup so the `expand: "supplier"` call returns a populated
|
||||
// We seed two in-memory maps: `stock_items` rows, `stock_ledger`
|
||||
// rows (for the bulk-import flow), and a small `suppliers` lookup
|
||||
// so the `expand: "supplier"` call returns a populated
|
||||
// `expand.supplier.suppliername` for the service's JS-side sort.
|
||||
|
||||
const items = new Map<string, Record<string, unknown>>();
|
||||
const ledger = new Map<string, Record<string, unknown>>();
|
||||
const suppliers = new Map<string, Record<string, unknown>>();
|
||||
|
||||
const getFullList = vi.fn(async (opts: { filter?: string; sort?: string; expand?: string } = {}) => {
|
||||
|
|
@ -63,10 +65,32 @@ const update = vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
|||
return { ...row };
|
||||
});
|
||||
|
||||
let createSeq = 0;
|
||||
|
||||
const create = vi.fn(async (data: Record<string, unknown>) => {
|
||||
createSeq++;
|
||||
const id = `mock-${createSeq}`;
|
||||
const row = {
|
||||
id,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
...data,
|
||||
};
|
||||
// The bulk-import flow writes to `stock_ledger` for qty
|
||||
// adjustments and cost moves. Other collections are
|
||||
// out-of-scope for the existing tests; we track the create
|
||||
// call so callers can inspect what was written.
|
||||
if (data.stock_item !== undefined) {
|
||||
ledger.set(id, row);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const pb = {
|
||||
collection: (id: string) => {
|
||||
if (id !== "stock_items") throw new Error(`unexpected collection: ${id}`);
|
||||
return { getFullList, getOne, update };
|
||||
if (id === "stock_items") return { getFullList, getOne, update, create };
|
||||
if (id === "stock_ledger") return { create };
|
||||
throw new Error(`unexpected collection: ${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -77,6 +101,9 @@ vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|||
import {
|
||||
listStockItemsForStocktake,
|
||||
updateStockItemWithPb,
|
||||
buildBulkImportPlan,
|
||||
executeBulkImportPlan,
|
||||
type ParsedImportRow,
|
||||
} from "./stock-items-service";
|
||||
import type { StockItem } from "@/lib/types";
|
||||
|
||||
|
|
@ -90,10 +117,13 @@ function seedItem(id: string, fields: Record<string, unknown>) {
|
|||
|
||||
beforeEach(() => {
|
||||
items.clear();
|
||||
ledger.clear();
|
||||
suppliers.clear();
|
||||
getFullList.mockClear();
|
||||
getOne.mockClear();
|
||||
update.mockClear();
|
||||
create.mockClear();
|
||||
createSeq = 0;
|
||||
});
|
||||
|
||||
// --- Tests -------------------------------------------------------------------
|
||||
|
|
@ -365,3 +395,242 @@ describe("updateStockItemWithPb", () => {
|
|||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- executeBulkImportPlan (auto-markup wiring) -----------------------------
|
||||
|
||||
describe("executeBulkImportPlan (auto-markup wiring)", () => {
|
||||
const ACTOR = { id: "user-1", name: "David Els" };
|
||||
const existingById = new Map<string, StockItem>();
|
||||
const existingBySku = new Map<string, StockItem>();
|
||||
|
||||
function refreshLookupMaps() {
|
||||
existingById.clear();
|
||||
existingBySku.clear();
|
||||
for (const row of items.values()) {
|
||||
if (!row.id || !row.sku) continue;
|
||||
existingById.set(String(row.id), row as unknown as StockItem);
|
||||
existingBySku.set(String(row.sku), row as unknown as StockItem);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a single-row create plan via the planner, for the auto-markup wiring tests. */
|
||||
function planForRow(row: ParsedImportRow) {
|
||||
refreshLookupMaps();
|
||||
return buildBulkImportPlan({
|
||||
rows: [row],
|
||||
existingById,
|
||||
existingBySku,
|
||||
supplierLookup: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
it("auto-populates selling_price + markup on create when cost_price > 0 and selling/markup are blank", async () => {
|
||||
const row: ParsedImportRow = {
|
||||
__rowNumber: 2,
|
||||
id: "",
|
||||
sku: "NEW-001",
|
||||
name: "Widget A",
|
||||
cost_price: "50",
|
||||
};
|
||||
const plan = planForRow(row);
|
||||
expect(plan.plans).toHaveLength(1);
|
||||
const first = plan.plans[0];
|
||||
expect(first?.kind).toBe("create");
|
||||
if (first?.kind !== "create") return;
|
||||
|
||||
const result = await executeBulkImportPlan({
|
||||
plan,
|
||||
actor: ACTOR,
|
||||
pb: pb as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.created).toHaveLength(1);
|
||||
|
||||
// Inspect the captured create call to PB: it must include
|
||||
// selling_price = round(50 * 1.80, 2) = 90 and markup = 80.
|
||||
const createCall = create.mock.calls.find(
|
||||
(c) => (c[0] as { sku?: string }).sku === "NEW-001",
|
||||
);
|
||||
expect(createCall).toBeDefined();
|
||||
const payload = createCall?.[0] as Record<string, unknown>;
|
||||
expect(payload.cost_price).toBe(50);
|
||||
expect(payload.selling_price).toBe(90);
|
||||
expect(payload.markup).toBe(80);
|
||||
// cost_history should also be bootstrapped with a single entry.
|
||||
expect(Array.isArray(payload.cost_history)).toBe(true);
|
||||
expect((payload.cost_history as unknown[])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does NOT auto-set selling_price on create when the spreadsheet already supplies it", async () => {
|
||||
const row: ParsedImportRow = {
|
||||
__rowNumber: 2,
|
||||
id: "",
|
||||
sku: "NEW-002",
|
||||
name: "Widget B",
|
||||
cost_price: "100",
|
||||
selling_price: "175",
|
||||
};
|
||||
const plan = planForRow(row);
|
||||
const result = await executeBulkImportPlan({
|
||||
plan,
|
||||
actor: ACTOR,
|
||||
pb: pb as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const createCall = create.mock.calls.find(
|
||||
(c) => (c[0] as { sku?: string }).sku === "NEW-002",
|
||||
);
|
||||
const payload = createCall?.[0] as Record<string, unknown>;
|
||||
// Operator-supplied selling_price wins.
|
||||
expect(payload.selling_price).toBe(175);
|
||||
// markup was not set by the operator in the spreadsheet →
|
||||
// planner's default (0) flows through; helper doesn't add
|
||||
// anything because selling_price was explicit.
|
||||
expect(payload.markup).toBe(0);
|
||||
});
|
||||
|
||||
it("auto-populates selling_price + markup on update when the existing row has none", async () => {
|
||||
seedItem("si-1", {
|
||||
sku: "OLD-001",
|
||||
name: "Existing Widget",
|
||||
cost_price: 0,
|
||||
selling_price: 0,
|
||||
markup: 0,
|
||||
cost_history: [],
|
||||
});
|
||||
// The operator's spreadsheet changes cost_price to 50 (and
|
||||
// leaves selling/markup blank — operator-style "I'll set the
|
||||
// cost, you figure out the shelf price").
|
||||
const row: ParsedImportRow = {
|
||||
__rowNumber: 2,
|
||||
id: "si-1",
|
||||
sku: "OLD-001",
|
||||
name: "Existing Widget",
|
||||
cost_price: "50",
|
||||
};
|
||||
const plan = planForRow(row);
|
||||
expect(plan.plans).toHaveLength(1);
|
||||
expect(plan.plans[0]?.kind).toBe("update");
|
||||
|
||||
const result = await executeBulkImportPlan({
|
||||
plan,
|
||||
actor: ACTOR,
|
||||
pb: pb as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.updated).toHaveLength(1);
|
||||
|
||||
// The captured update must include the auto-markup fields
|
||||
// alongside the operator-supplied cost_price.
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const updatePayload = update.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(updatePayload.cost_price).toBe(50);
|
||||
expect(updatePayload.selling_price).toBe(90); // 50 * 1.80
|
||||
expect(updatePayload.markup).toBe(80);
|
||||
});
|
||||
|
||||
it("does NOT auto-set selling_price on update when the existing row already has it", async () => {
|
||||
seedItem("si-2", {
|
||||
sku: "OLD-002",
|
||||
name: "Existing Widget (priced)",
|
||||
cost_price: 50,
|
||||
selling_price: 99.99,
|
||||
markup: 99.98, // legacy operator-set markup
|
||||
cost_history: [
|
||||
{
|
||||
cost_price: 50,
|
||||
quantity: 5,
|
||||
purchased_at: "2026-08-01T08:00:00.000Z",
|
||||
purchase_id: "PO26080101STK",
|
||||
},
|
||||
],
|
||||
});
|
||||
// Operator changes cost_price from 50 → 60 (cost-move
|
||||
// scenario). Selling_price and markup are intentionally left
|
||||
// blank in the spreadsheet — but the existing row has them,
|
||||
// so the helper MUST NOT touch them.
|
||||
const row: ParsedImportRow = {
|
||||
__rowNumber: 2,
|
||||
id: "si-2",
|
||||
sku: "OLD-002",
|
||||
name: "Existing Widget (priced)",
|
||||
cost_price: "60",
|
||||
};
|
||||
const plan = planForRow(row);
|
||||
|
||||
const result = await executeBulkImportPlan({
|
||||
plan,
|
||||
actor: ACTOR,
|
||||
pb: pb as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const updatePayload = update.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(updatePayload.cost_price).toBe(60);
|
||||
// selling_price / markup NOT in the patch (helper didn't touch
|
||||
// them because the existing row had non-zero values for both).
|
||||
expect(updatePayload.selling_price).toBeUndefined();
|
||||
expect(updatePayload.markup).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT auto-set markup when the patch already sets it to a non-zero value", async () => {
|
||||
seedItem("si-3", {
|
||||
sku: "OLD-003",
|
||||
name: "Existing Widget (markup-only)",
|
||||
cost_price: 50,
|
||||
selling_price: 0,
|
||||
markup: 0,
|
||||
cost_history: [],
|
||||
});
|
||||
// Operator sets markup to 30 but leaves selling_price blank.
|
||||
// The patch's markup is explicit and non-zero → helper
|
||||
// short-circuits (per the rule "never overwrite a non-zero
|
||||
// markup").
|
||||
const row: ParsedImportRow = {
|
||||
__rowNumber: 2,
|
||||
id: "si-3",
|
||||
sku: "OLD-003",
|
||||
name: "Existing Widget (markup-only)",
|
||||
markup: "30",
|
||||
};
|
||||
const plan = planForRow(row);
|
||||
|
||||
const result = await executeBulkImportPlan({
|
||||
plan,
|
||||
actor: ACTOR,
|
||||
pb: pb as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const updatePayload = update.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(updatePayload.markup).toBe(30); // patch's value, untouched
|
||||
// No selling_price — operator chose markup only; helper
|
||||
// didn't add it because markup was explicit.
|
||||
expect(updatePayload.selling_price).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rounds selling_price to 2dp (117.39 → 211.30)", async () => {
|
||||
const row: ParsedImportRow = {
|
||||
__rowNumber: 2,
|
||||
id: "",
|
||||
sku: "NEW-RND",
|
||||
name: "Widget Rounding",
|
||||
cost_price: "117.39",
|
||||
};
|
||||
const plan = planForRow(row);
|
||||
const result = await executeBulkImportPlan({
|
||||
plan,
|
||||
actor: ACTOR,
|
||||
pb: pb as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const createCall = create.mock.calls.find(
|
||||
(c) => (c[0] as { sku?: string }).sku === "NEW-RND",
|
||||
);
|
||||
const payload = createCall?.[0] as Record<string, unknown>;
|
||||
expect(payload.selling_price).toBe(211.30);
|
||||
expect(payload.markup).toBe(80);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { StockItem, Supplier } from "@/lib/types";
|
||||
import { applyDefaultMarkupToPatch } from "./stock-default-markup-service";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
/**
|
||||
|
|
@ -937,10 +938,23 @@ export async function executeBulkImportPlan(args: {
|
|||
if (supplierLookup && typeof createFields.supplier === "string" && createFields.supplier) {
|
||||
// Already an id (the planner resolves). No-op.
|
||||
}
|
||||
const createdRow = (await pb.collection("stock_items").create({
|
||||
// Route the create through `applyDefaultMarkupToPatch`
|
||||
// so a positive cost_price auto-populates selling_price +
|
||||
// markup at 80% (idempotent — never overwrites an
|
||||
// operator-set value) and bootstraps `cost_history` when
|
||||
// the row has none. The planner has already collapsed
|
||||
// any selling_price / markup fields the operator typed
|
||||
// in the spreadsheet — those win over the helper.
|
||||
const createPatch = applyDefaultMarkupToPatch(
|
||||
{
|
||||
...createFields,
|
||||
cost_history: [],
|
||||
})) as { id: string; sku?: string; name?: string };
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
const createdRow = (await pb.collection("stock_items").create(
|
||||
createPatch,
|
||||
)) as { id: string; sku?: string; name?: string };
|
||||
created.push({
|
||||
rowNumber: p.rowNumber,
|
||||
stockItemId: createdRow.id,
|
||||
|
|
@ -950,6 +964,41 @@ export async function executeBulkImportPlan(args: {
|
|||
continue;
|
||||
}
|
||||
if (p.kind === "update") {
|
||||
// 0. Load the existing row so the helper can honour
|
||||
// idempotency (don't auto-set selling_price / markup
|
||||
// if the row already has them, and don't seed
|
||||
// cost_history if the row already has history).
|
||||
// Best-effort: if the row was deleted between plan
|
||||
// build and write, we fall through with no existing
|
||||
// data and the helper treats the patch as a fresh row.
|
||||
let existingItem: {
|
||||
cost_price?: number;
|
||||
selling_price?: number;
|
||||
markup?: number;
|
||||
cost_history?: unknown;
|
||||
} | undefined;
|
||||
try {
|
||||
const fetched = (await pb
|
||||
.collection("stock_items")
|
||||
.getOne(p.stockItemId)) as Record<string, unknown>;
|
||||
existingItem = {
|
||||
cost_price:
|
||||
typeof fetched.cost_price === "number"
|
||||
? (fetched.cost_price as number)
|
||||
: undefined,
|
||||
selling_price:
|
||||
typeof fetched.selling_price === "number"
|
||||
? (fetched.selling_price as number)
|
||||
: undefined,
|
||||
markup:
|
||||
typeof fetched.markup === "number"
|
||||
? (fetched.markup as number)
|
||||
: undefined,
|
||||
cost_history: fetched.cost_history,
|
||||
};
|
||||
} catch {
|
||||
existingItem = undefined;
|
||||
}
|
||||
// 1. If qty changed, write a stock_ledger row first.
|
||||
let ledgerId: string | undefined;
|
||||
if (p.qtyDelta !== 0) {
|
||||
|
|
@ -985,7 +1034,7 @@ export async function executeBulkImportPlan(args: {
|
|||
// supplier *name* (for human-readable diffs), but PB
|
||||
// stores the relation *id*. Resolve the name → id
|
||||
// here. Any other field is patched as-is.
|
||||
const patch: Record<string, unknown> = {};
|
||||
const rawPatch: Record<string, unknown> = {};
|
||||
for (const change of p.changes) {
|
||||
if (change.field === "supplier") {
|
||||
if (typeof change.after === "string" && change.after) {
|
||||
|
|
@ -995,14 +1044,27 @@ export async function executeBulkImportPlan(args: {
|
|||
`supplier "${change.after}" not found — add it in WFDASH first or fix the spelling`,
|
||||
);
|
||||
}
|
||||
patch.supplier = id;
|
||||
rawPatch.supplier = id;
|
||||
} else {
|
||||
patch.supplier = "";
|
||||
rawPatch.supplier = "";
|
||||
}
|
||||
} else {
|
||||
patch[change.field] = change.after;
|
||||
rawPatch[change.field] = change.after;
|
||||
}
|
||||
}
|
||||
// Route through `applyDefaultMarkupToPatch` so that an
|
||||
// operator who fills in a `cost_price` but leaves
|
||||
// `selling_price` / `markup` blank in the spreadsheet
|
||||
// still gets the auto-markup applied at write time. The
|
||||
// helper sees the existing row (read above) so it can
|
||||
// honour idempotency — never overwrites an operator-set
|
||||
// price or an existing non-empty `markup`.
|
||||
const patch = applyDefaultMarkupToPatch(rawPatch, {
|
||||
cost_price: existingItem?.cost_price,
|
||||
selling_price: existingItem?.selling_price,
|
||||
markup: existingItem?.markup,
|
||||
cost_history: existingItem?.cost_history,
|
||||
});
|
||||
await pb.collection("stock_items").update(p.stockItemId, patch);
|
||||
updated.push({
|
||||
rowNumber: p.rowNumber,
|
||||
|
|
|
|||
|
|
@ -567,6 +567,10 @@ describe("addQuickDraftLine", () => {
|
|||
purchase_id: "",
|
||||
});
|
||||
expect(typeof history[0].purchased_at).toBe("string");
|
||||
// Auto-markup fires: cost > 0, no operator-set selling/markup.
|
||||
// 7.25 * 1.80 = 13.05.
|
||||
expect(items[0].selling_price).toBe(13.05);
|
||||
expect(items[0].markup).toBe(80);
|
||||
});
|
||||
|
||||
it("does not seed cost_price when newStockItem.costPrice is omitted (legacy behaviour)", async () => {
|
||||
|
|
@ -589,6 +593,9 @@ describe("addQuickDraftLine", () => {
|
|||
// empty cost_history. Existing callers see no change.
|
||||
expect(items[0].cost_price).toBeUndefined();
|
||||
expect(items[0].cost_history).toEqual([]);
|
||||
// No cost → no auto-markup either.
|
||||
expect(items[0].selling_price).toBeUndefined();
|
||||
expect(items[0].markup).toBeUndefined();
|
||||
});
|
||||
|
||||
it("backfills cost_price + appends cost_history when the existing stock_item has cost_price = 0", async () => {
|
||||
|
|
@ -622,6 +629,9 @@ describe("addQuickDraftLine", () => {
|
|||
quantity: 0,
|
||||
purchase_id: "",
|
||||
});
|
||||
// Auto-markup fires on the backfill: 18.5 * 1.80 = 33.30.
|
||||
expect(updated.selling_price).toBe(33.3);
|
||||
expect(updated.markup).toBe(80);
|
||||
});
|
||||
|
||||
it("does not clobber an existing non-zero cost_price, but still appends to cost_history", async () => {
|
||||
|
|
@ -663,6 +673,10 @@ describe("addQuickDraftLine", () => {
|
|||
quantity: 0,
|
||||
purchase_id: "",
|
||||
});
|
||||
// The row had cost_price=22 but selling_price=0 and markup=0,
|
||||
// so the helper fills them in at 80% — 22 * 1.80 = 39.60.
|
||||
expect(updated.selling_price).toBe(39.6);
|
||||
expect(updated.markup).toBe(80);
|
||||
});
|
||||
|
||||
it("rejects non-positive quantities", async () => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
import type PocketBase from "pocketbase";
|
||||
import type { StockCostHistoryEntry } from "@/lib/types";
|
||||
import { applyDefaultMarkupToPatch } from "./stock-default-markup-service";
|
||||
import { generateStockPurchaseNumber } from "./stock-purchases-service";
|
||||
|
||||
const STOCK_ITEMS = "stock_items";
|
||||
|
|
@ -435,27 +436,16 @@ export async function addQuickDraftLine(
|
|||
const inlineCost = input.newStockItem.costPrice;
|
||||
const hasInlineCost =
|
||||
typeof inlineCost === "number" && Number.isFinite(inlineCost) && inlineCost >= 0;
|
||||
// Bootstrap a one-entry cost_history when the buyer supplied
|
||||
// an inline cost on a brand-new item. Matches
|
||||
// `StockCostHistoryEntry` shape (see `lib/types.ts`):
|
||||
// `cost_price`, `quantity` (0 — the buyer hasn't booked the
|
||||
// invoice yet), `purchased_at` (now, ISO), `purchase_id` ("" —
|
||||
// this is a manual seed, not tied to a stock_purchase row).
|
||||
// The accountant's `bookStockFromInvoice` flow appends a
|
||||
// second entry with a real purchase_id when the supplier
|
||||
// invoice is booked — so the inline seed is just the "we
|
||||
// knew the price when we typed it" snapshot.
|
||||
const seedHistory: StockCostHistoryEntry[] = hasInlineCost
|
||||
? [
|
||||
{
|
||||
cost_price: inlineCost as number,
|
||||
quantity: 0,
|
||||
purchased_at: new Date().toISOString(),
|
||||
purchase_id: "",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const created = await pb.collection(STOCK_ITEMS).create({
|
||||
// Build the operator-supplied portion of the patch, then run
|
||||
// `applyDefaultMarkupToPatch` over it. When `inlineCost` is
|
||||
// positive AND the operator didn't separately override the
|
||||
// shelf price or markup, the helper auto-populates
|
||||
// `selling_price = round(cost * 1.80, 2)` and `markup = 80`.
|
||||
// Same idempotency rules as the operator CLI backfill:
|
||||
// never overwrites an operator-set price. Pass `undefined`
|
||||
// for the existing row (no row yet — this is the create
|
||||
// path).
|
||||
const inlinePatch: Record<string, unknown> = {
|
||||
name: input.newStockItem.name,
|
||||
sku: input.newStockItem.sku,
|
||||
description: input.newStockItem.description ?? "",
|
||||
|
|
@ -463,10 +453,12 @@ export async function addQuickDraftLine(
|
|||
current_qty: 0,
|
||||
is_active: true,
|
||||
supplier: input.supplierId,
|
||||
...(hasInlineCost
|
||||
? { cost_price: inlineCost, cost_history: seedHistory }
|
||||
: { cost_history: [] }),
|
||||
});
|
||||
cost_history: [],
|
||||
};
|
||||
if (hasInlineCost) inlinePatch.cost_price = inlineCost;
|
||||
const created = await pb.collection(STOCK_ITEMS).create(
|
||||
applyDefaultMarkupToPatch(inlinePatch, undefined),
|
||||
);
|
||||
stockItemId = String(created.id);
|
||||
inlineCreated = true;
|
||||
} catch (err) {
|
||||
|
|
@ -521,9 +513,17 @@ export async function addQuickDraftLine(
|
|||
// - On the inline-create path the seed step above already
|
||||
// handled `cost_price` + `cost_history` (or intentionally
|
||||
// skipped both). Skip the backfill so we don't double-write.
|
||||
// - The patch is then run through `applyDefaultMarkupToPatch`
|
||||
// with the existing row's selling_price / markup / cost.
|
||||
// When the row has a positive cost AND no operator-set
|
||||
// selling/markup, the helper fills them in at 80%. The
|
||||
// helper does NOT touch `cost_history` when the patch has
|
||||
// already appended an entry — the appended entry wins.
|
||||
if (!inlineCreated) {
|
||||
try {
|
||||
const existingCostPrice = Number(stockItem.cost_price ?? 0);
|
||||
const existingSellingPrice = Number(stockItem.selling_price ?? 0);
|
||||
const existingMarkup = Number(stockItem.markup ?? 0);
|
||||
const existingHistory = Array.isArray(stockItem.cost_history)
|
||||
? (stockItem.cost_history as unknown as StockCostHistoryEntry[])
|
||||
: [];
|
||||
|
|
@ -535,16 +535,19 @@ export async function addQuickDraftLine(
|
|||
purchased_at: new Date().toISOString(),
|
||||
purchase_id: "",
|
||||
};
|
||||
const patch: {
|
||||
cost_price?: number;
|
||||
cost_history: StockCostHistoryEntry[];
|
||||
} = {
|
||||
const basePatch: Record<string, unknown> = {
|
||||
cost_history: [...existingHistory, newEntry],
|
||||
};
|
||||
if (shouldBackfill) {
|
||||
patch.cost_price = input.costPrice;
|
||||
basePatch.cost_price = input.costPrice;
|
||||
}
|
||||
await pb.collection(STOCK_ITEMS).update(finalStockItemId, patch);
|
||||
const finalPatch = applyDefaultMarkupToPatch(basePatch, {
|
||||
cost_price: existingCostPrice,
|
||||
selling_price: existingSellingPrice,
|
||||
markup: existingMarkup,
|
||||
cost_history: stockItem.cost_history,
|
||||
});
|
||||
await pb.collection(STOCK_ITEMS).update(finalStockItemId, finalPatch);
|
||||
} catch {
|
||||
// Cost-patch failures must NOT block the line append. The line
|
||||
// is the buyer's primary intent; the cost backfill is a
|
||||
|
|
|
|||
Loading…
Reference in a new issue