@spinekit/purchase 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +75 -0
  3. package/README.md +40 -0
  4. package/dist/bridges.d.mts +167 -0
  5. package/dist/bridges.mjs +151 -0
  6. package/dist/entity-model-D-03ovSg.mjs +23 -0
  7. package/dist/index.d.mts +5 -0
  8. package/dist/index.mjs +140 -0
  9. package/dist/lifecycle/purchase-lifecycle.d.mts +17 -0
  10. package/dist/lifecycle/purchase-lifecycle.mjs +120 -0
  11. package/dist/lifecycle/purchase-lifecycle.types.d.mts +32 -0
  12. package/dist/lifecycle/purchase-lifecycle.types.mjs +1 -0
  13. package/dist/payment/purchase-payment.application.d.mts +8 -0
  14. package/dist/payment/purchase-payment.application.mjs +154 -0
  15. package/dist/payment/purchase-payment.tax.d.mts +30 -0
  16. package/dist/payment/purchase-payment.tax.mjs +37 -0
  17. package/dist/payment/purchase-payment.types.d.mts +2 -0
  18. package/dist/payment/purchase-payment.types.mjs +1 -0
  19. package/dist/purchase-payment.types-hVKAeW3G.d.mts +153 -0
  20. package/dist/receipt/purchase-stock-receipt.d.mts +12 -0
  21. package/dist/receipt/purchase-stock-receipt.mjs +225 -0
  22. package/dist/receipt/purchase-stock-receipt.types.d.mts +167 -0
  23. package/dist/receipt/purchase-stock-receipt.types.mjs +1 -0
  24. package/dist/repositories/purchase-order.repository.d.mts +40 -0
  25. package/dist/repositories/purchase-order.repository.mjs +111 -0
  26. package/dist/resources/purchase-order/purchase-order.resource.d.mts +56 -0
  27. package/dist/resources/purchase-order/purchase-order.resource.mjs +193 -0
  28. package/dist/resources/supplier/supplier.model.d.mts +2 -0
  29. package/dist/resources/supplier/supplier.model.mjs +178 -0
  30. package/dist/resources/supplier/supplier.repository.d.mts +20 -0
  31. package/dist/resources/supplier/supplier.repository.mjs +62 -0
  32. package/dist/resources/supplier/supplier.resource.d.mts +34 -0
  33. package/dist/resources/supplier/supplier.resource.mjs +129 -0
  34. package/dist/resources/supplier/supplier.types.d.mts +2 -0
  35. package/dist/resources/supplier/supplier.types.mjs +1 -0
  36. package/dist/supplier.model-BcAfgyHu.d.mts +88 -0
  37. package/dist/types-T9gsXOf_.d.mts +127 -0
  38. package/package.json +129 -0
@@ -0,0 +1,193 @@
1
+ import { n as asRecord, r as asRecordRepository, t as asEntityModel } from "../../entity-model-D-03ovSg.mjs";
2
+ import { BaseController, defineResource } from "@classytic/arc";
3
+ import { permissionMatrix } from "@classytic/arc/permissions";
4
+ import { getUserId, scopeFirstCtx } from "@classytic/arc/scope";
5
+ import { createDomainError } from "@classytic/arc/utils";
6
+ import { createMongooseAdapter } from "@classytic/mongokit/adapter";
7
+ import { QueryParser } from "@classytic/mongokit";
8
+ import { CreateOrderSchema, UpdateOrderSchema } from "@classytic/purchase/schemas";
9
+ //#region src/resources/purchase-order/purchase-order.resource.ts
10
+ /**
11
+ * Purchase Orders — supplier procurement over the `@classytic/purchase`
12
+ * kernel (draft → approved → received, cancel), inheriting the kernel's
13
+ * hard-won semantics for free:
14
+ *
15
+ * - `receive()`: FSM-guarded CAS, bridge compensation, and the
16
+ * `pendingStockReceipt` CRASH HEAL — a retry after a crashed receive
17
+ * re-fires the idempotent stockReceipt bridge instead of stranding the
18
+ * order (or silently losing WMS stock).
19
+ * - Money is PAISA end-to-end (`costPrice`, discounts, taxes, totals —
20
+ * integer minor units). The wire passes through untouched.
21
+ * - `expectedDeliveryDate` = the OTIF baseline for supplier scoring.
22
+ *
23
+ * THE MODULE OWNS HTTP — there are no controller/repository/schema bypass
24
+ * seams. CREATE and UPDATE route through the kernel verbs (`createOrder`,
25
+ * `updateDraft`: numbering, catalog cost lookup, FX pairing, totals, FSM
26
+ * guard); the wire contract is the kernel's own Zod schemas; DELETE is a
27
+ * hard 405 (purchases are an immutable audit trail — corrections post a
28
+ * new order, cancel is the verb). Hosts extend via `extraActions` /
29
+ * `extraRoutes` (which ADD behavior) and `readPopulate` (display joins on
30
+ * HTTP read paths) — none of which can bypass the kernel.
31
+ *
32
+ * Branch scoping: the kernel owns a required `branch` field and be-prod runs
33
+ * engine tenancy OFF (arc's RequestScope is the boundary) — mirrored here
34
+ * with `tenantField: false` + `branch` filterable.
35
+ */
36
+ function ctx(req) {
37
+ const organizationId = scopeFirstCtx(req).organizationId;
38
+ const actorId = getUserId(req.scope);
39
+ return {
40
+ ...organizationId ? { organizationId } : {},
41
+ ...actorId ? { actorId } : {}
42
+ };
43
+ }
44
+ function ctxFromController(context) {
45
+ const organizationId = context.scope?.organizationId;
46
+ const actorId = String(context.user?._id ?? context.user?.id ?? "") || void 0;
47
+ return {
48
+ ...organizationId ? { organizationId } : {},
49
+ ...actorId ? { actorId } : {}
50
+ };
51
+ }
52
+ const SYSTEM_MANAGED_FIELD_RULES = {
53
+ invoiceNumber: { systemManaged: true },
54
+ status: { systemManaged: true },
55
+ grandTotal: { systemManaged: true },
56
+ dueAmount: { systemManaged: true },
57
+ paymentStatus: { systemManaged: true },
58
+ statusHistory: { systemManaged: true },
59
+ pendingStockReceipt: { systemManaged: true },
60
+ approvals: { systemManaged: true },
61
+ approvalPolicyId: { systemManaged: true },
62
+ approvalPolicyVersion: { systemManaged: true }
63
+ };
64
+ function createPurchaseOrderResource(args) {
65
+ const { engine, permissions, prefix = "/inventory/purchase-orders", extraActions, extraRoutes, readPopulate } = args;
66
+ const orders = engine.repositories.purchaseOrder;
67
+ if (readPopulate?.length) {
68
+ const inject = (payload) => {
69
+ const c = payload;
70
+ if (c.populate) return;
71
+ c.populate = readPopulate;
72
+ };
73
+ orders.on("before:getAll", inject);
74
+ orders.on("before:getByQuery", inject);
75
+ orders.on("before:getOne", inject);
76
+ }
77
+ class PurchaseOrderModuleController extends BaseController {
78
+ constructor() {
79
+ super(engine.repositories.purchaseOrder, {
80
+ resourceName: "purchase-order",
81
+ tenantField: false,
82
+ schemaOptions: { fieldRules: SYSTEM_MANAGED_FIELD_RULES }
83
+ });
84
+ }
85
+ /**
86
+ * `create` and `update` are NOT overridden — they are declared as `writes`
87
+ * on the resource below (arc 2.34+).
88
+ *
89
+ * They used to be overrides, and an override replaces arc's whole write
90
+ * pipeline: `context.body` went to the kernel verb RAW, so every rule in
91
+ * `SYSTEM_MANAGED_FIELD_RULES` above — `grandTotal`, `dueAmount`,
92
+ * `paymentStatus`, `approvals`, `statusHistory`, `approvalPolicyId` —
93
+ * was declared and enforced nowhere. On a document that carries money and
94
+ * an approval chain, that is the approval gate itself being writable by
95
+ * the request it gates. The identical shape was measured live on the
96
+ * invoice resource: a `PATCH` on a draft wrote `status: "posted"` and a
97
+ * forged document `number`, answering `200`.
98
+ *
99
+ * `writes` keeps the kernel verb AND the pipeline.
100
+ */
101
+ /** Purchases are an immutable audit trail — corrections post a new
102
+ * order; `action: cancel` is the lifecycle verb (kernel FSM). */
103
+ async delete() {
104
+ throw createDomainError("purchase.delete_forbidden", "Deleting purchase orders is not allowed — use action:cancel (immutable audit trail)", 405);
105
+ }
106
+ /** Detail-path display joins (see `readPopulate`). */
107
+ async executeGetQuery(id, options, req) {
108
+ const withPopulate = readPopulate?.length ? {
109
+ ...options,
110
+ populate: options.populate ?? readPopulate
111
+ } : options;
112
+ return super.executeGetQuery(id, withPopulate, req);
113
+ }
114
+ }
115
+ return defineResource({
116
+ name: "purchase-order",
117
+ displayName: "Purchase Orders",
118
+ tag: "Inventory",
119
+ prefix,
120
+ audit: true,
121
+ tenantField: false,
122
+ adapter: createMongooseAdapter({
123
+ model: asEntityModel(engine.models.PurchaseOrder),
124
+ repository: asRecordRepository(engine.repositories.purchaseOrder)
125
+ }),
126
+ controller: new PurchaseOrderModuleController(),
127
+ /**
128
+ * The write slots ARE the kernel verbs — document numbering
129
+ * (SequenceBridge), pricelist cost lookup, FX pairing, paisa totals and the
130
+ * FSM guards all live there, and a raw adapter write can never be correct.
131
+ *
132
+ * Declared rather than overridden so arc still sanitizes the body against
133
+ * `SYSTEM_MANAGED_FIELD_RULES` first. `delete` stays a controller override:
134
+ * it blocks the op outright with a typed 405 and never touches a body.
135
+ */
136
+ writes: {
137
+ create: async (data, wctx) => asRecord(await orders.createOrder(data, ctxFromController(wctx.req))),
138
+ update: async (id, data, wctx) => asRecord(await orders.updateDraft(id, data, ctxFromController(wctx.req)))
139
+ },
140
+ queryParser: new QueryParser({
141
+ maxLimit: 100,
142
+ allowedFilterFields: [
143
+ "status",
144
+ "paymentStatus",
145
+ "invoiceNumber",
146
+ "branch",
147
+ "supplier",
148
+ "paymentTerms",
149
+ "expectedDeliveryDate",
150
+ "reference"
151
+ ],
152
+ allowedSortFields: [
153
+ "createdAt",
154
+ "updatedAt",
155
+ "invoiceDate",
156
+ "dueDate",
157
+ "grandTotal",
158
+ "invoiceNumber",
159
+ "status",
160
+ "expectedDeliveryDate",
161
+ "receivedAt"
162
+ ]
163
+ }),
164
+ permissions: permissionMatrix({
165
+ read: permissions.view,
166
+ write: permissions.manage,
167
+ delete: permissions.manage
168
+ }),
169
+ schemaOptions: { fieldRules: SYSTEM_MANAGED_FIELD_RULES },
170
+ customSchemas: {
171
+ create: { body: CreateOrderSchema },
172
+ update: { body: UpdateOrderSchema }
173
+ },
174
+ routes: [...extraRoutes ?? []],
175
+ actions: {
176
+ approve: {
177
+ permissions: permissions.operate,
178
+ handler: async (id, _d, req) => orders.approve(id, ctx(req))
179
+ },
180
+ receive: {
181
+ permissions: permissions.operate,
182
+ handler: async (id, _d, req) => orders.receive(id, ctx(req))
183
+ },
184
+ cancel: {
185
+ permissions: permissions.cancel,
186
+ handler: async (id, data, req) => orders.cancel(id, data?.reason ?? "cancelled", ctx(req))
187
+ },
188
+ ...extraActions ?? {}
189
+ }
190
+ });
191
+ }
192
+ //#endregion
193
+ export { createPurchaseOrderResource };
@@ -0,0 +1,2 @@
1
+ import { n as SUPPLIER_TYPES, r as createSupplierModel, t as SUPPLIER_PAYMENT_TERMS } from "../../supplier.model-BcAfgyHu.mjs";
2
+ export { SUPPLIER_PAYMENT_TERMS, SUPPLIER_TYPES, createSupplierModel };
@@ -0,0 +1,178 @@
1
+ import { defineModel } from "@classytic/mongokit";
2
+ import { Schema } from "mongoose";
3
+ //#region src/resources/supplier/supplier.model.ts
4
+ /**
5
+ * Supplier — the PROCUREMENT / A-P facet of a business partner.
6
+ *
7
+ * Lifted from be-prod. Identity belongs to `@classytic/party`: this document carries `partyId`
8
+ * and the party owns the name, the contacts and the `supplier` role. What lives here is what
9
+ * only procurement cares about — a code, payment terms, a credit line, an opening balance.
10
+ *
11
+ * ## Jurisdiction lives in `extraFields`, not in this file
12
+ *
13
+ * be-prod's version declared a block of Bangladesh NBR fields inline (BIN, VDS withholding rate,
14
+ * TDS payee category, bonded-warehouse flag, fiscal position). Those are a COUNTRY PACK's
15
+ * vocabulary, and a package that names them can only ever serve one country — so the pack
16
+ * contributes them through `extraFields` / `extraIndexes`, and the generic facet stays generic.
17
+ * Nothing about the shape changed; only who declares it.
18
+ *
19
+ * ## Registration goes through `defineModel`
20
+ *
21
+ * The host had `mongoose.models.Supplier || mongoose.model('Supplier', schema)`. That guard is
22
+ * the one mongokit's `defineModel` replaced, and its failure mode is the reason: mongoose LOCKS a
23
+ * schema on the first `model()` call, so a second caller contributing fields or indexes has them
24
+ * **silently dropped**. `onExisting: 'reuse'` states that this registration adds nothing new; a
25
+ * caller that does contribute must pass `'throw'` and find out.
26
+ */
27
+ /** Sourcing relationship — generic enough for any jurisdiction. */
28
+ const SUPPLIER_TYPES = [
29
+ "local",
30
+ "import",
31
+ "manufacturer",
32
+ "wholesaler"
33
+ ];
34
+ /** Settlement basis. Terms in DAYS live on `creditDays`, so this stays a two-value axis. */
35
+ const SUPPLIER_PAYMENT_TERMS = ["cash", "credit"];
36
+ function createSupplierModel(deps) {
37
+ const { connection, refs, extraFields, extraIndexes, modelName = "Supplier" } = deps;
38
+ return defineModel(modelName, () => {
39
+ const schema = new Schema({
40
+ /**
41
+ * Canonical identity link, stamped by the host's party linker on create. The facet
42
+ * never owns identity — see `createPartyLinker`.
43
+ */
44
+ partyId: {
45
+ type: Schema.Types.ObjectId,
46
+ ref: refs.party,
47
+ index: true
48
+ },
49
+ name: {
50
+ type: String,
51
+ required: true,
52
+ trim: true
53
+ },
54
+ /**
55
+ * Lower-cased, trimmed name backing the unique-among-active index below. Maintained by
56
+ * the repository's create/update hooks — a write that bypasses them leaves it unset,
57
+ * which is why the partial index is on `isActive` rather than on the whole collection.
58
+ */
59
+ nameNormalized: {
60
+ type: String,
61
+ trim: true,
62
+ lowercase: true
63
+ },
64
+ code: {
65
+ type: String,
66
+ trim: true,
67
+ uppercase: true
68
+ },
69
+ type: {
70
+ type: String,
71
+ enum: [...SUPPLIER_TYPES],
72
+ default: "local"
73
+ },
74
+ contactPerson: {
75
+ type: String,
76
+ trim: true
77
+ },
78
+ phone: {
79
+ type: String,
80
+ trim: true
81
+ },
82
+ email: {
83
+ type: String,
84
+ trim: true,
85
+ lowercase: true
86
+ },
87
+ address: {
88
+ type: String,
89
+ trim: true
90
+ },
91
+ /** Generic tax registration id. A jurisdiction's OWN identifiers go in `extraFields`. */
92
+ taxId: {
93
+ type: String,
94
+ trim: true
95
+ },
96
+ paymentTerms: {
97
+ type: String,
98
+ enum: [...SUPPLIER_PAYMENT_TERMS],
99
+ default: "cash"
100
+ },
101
+ creditDays: {
102
+ type: Number,
103
+ default: 0,
104
+ min: 0
105
+ },
106
+ creditLimit: {
107
+ type: Number,
108
+ min: 0,
109
+ default: 0
110
+ },
111
+ openingBalance: {
112
+ type: Number,
113
+ default: 0
114
+ },
115
+ /** Business flag for "temporarily not ordering" — distinct from `deletedAt`. */
116
+ isActive: {
117
+ type: Boolean,
118
+ default: true,
119
+ index: true
120
+ },
121
+ notes: {
122
+ type: String,
123
+ trim: true
124
+ },
125
+ tags: {
126
+ type: [String],
127
+ default: []
128
+ },
129
+ createdBy: {
130
+ type: Schema.Types.ObjectId,
131
+ ref: refs.user
132
+ },
133
+ updatedBy: {
134
+ type: Schema.Types.ObjectId,
135
+ ref: refs.user
136
+ },
137
+ /**
138
+ * Soft-delete marker for mongokit's `softDeletePlugin`. Declared with `default: null`
139
+ * so a new document matches the plugin's `{ deletedAt: null }` filter — without the
140
+ * default, every freshly created supplier is invisible to its own list.
141
+ */
142
+ deletedAt: {
143
+ type: Date,
144
+ default: null,
145
+ index: true
146
+ },
147
+ ...extraFields ?? {}
148
+ }, { timestamps: true });
149
+ schema.index({
150
+ name: 1,
151
+ isActive: 1
152
+ });
153
+ schema.index({ code: 1 }, {
154
+ unique: true,
155
+ sparse: true
156
+ });
157
+ /**
158
+ * One ACTIVE supplier per normalized name. Partial rather than plain-unique so an
159
+ * archived or deactivated supplier does not block re-onboarding the same vendor.
160
+ */
161
+ schema.index({ nameNormalized: 1 }, {
162
+ unique: true,
163
+ partialFilterExpression: { isActive: true }
164
+ });
165
+ for (const [fields, options] of extraIndexes ?? []) schema.index(fields, options);
166
+ return schema;
167
+ }, {
168
+ connection,
169
+ /**
170
+ * This factory is the ONLY declaration of the collection, so re-entry adds nothing. A pack
171
+ * that needs more fields passes them here via `extraFields` — reaching for a second
172
+ * `defineModel` call on the same name would drop them.
173
+ */
174
+ onExisting: "reuse"
175
+ });
176
+ }
177
+ //#endregion
178
+ export { SUPPLIER_PAYMENT_TERMS, SUPPLIER_TYPES, createSupplierModel };
@@ -0,0 +1,20 @@
1
+ import { a as SupplierFacet, l as SupplierRepositoryDeps } from "../../supplier.model-BcAfgyHu.mjs";
2
+ import { Repository } from "@classytic/mongokit";
3
+ import { Model } from "mongoose";
4
+ //#region src/resources/supplier/supplier.repository.d.ts
5
+ declare class SupplierRepository<TSupplier extends SupplierFacet = SupplierFacet> extends Repository<TSupplier> {
6
+ constructor(model: Model<TSupplier>, deps?: SupplierRepositoryDeps);
7
+ /**
8
+ * One-time migration for rows written before `nameNormalized` existed.
9
+ *
10
+ * A pipeline update, so the value is derived from each document's own `name` in the database
11
+ * rather than read-modify-written per row. Rows that already carry a value are left alone: the
12
+ * filter is the guard, so a re-run is a no-op rather than a rewrite.
13
+ */
14
+ backfillNameNormalized(): Promise<{
15
+ modifiedCount: number;
16
+ }>;
17
+ }
18
+ declare function createSupplierRepository<TSupplier extends SupplierFacet = SupplierFacet>(model: Model<TSupplier>, deps?: SupplierRepositoryDeps): SupplierRepository<TSupplier>;
19
+ //#endregion
20
+ export { SupplierRepository, createSupplierRepository };
@@ -0,0 +1,62 @@
1
+ import { Repository, requireField, softDeletePlugin, uniqueField, validationChainPlugin } from "@classytic/mongokit";
2
+ //#region src/resources/supplier/supplier.repository.ts
3
+ /**
4
+ * Supplier repository — mongokit `Repository` plus the two write-time invariants.
5
+ *
6
+ * Lifted from be-prod.
7
+ *
8
+ * - **`validationChainPlugin`** — `name` required on create, `code` unique with a readable
9
+ * message instead of a raw duplicate-key error.
10
+ * - **`softDeletePlugin`** — DELETE archives. Historical purchase documents still render the
11
+ * vendor name because a direct mongoose `populate` bypasses the plugin's filter, which is
12
+ * deliberate: the A/P ledger must stay readable after a vendor is archived.
13
+ * - **normalization hooks** — trim the name and maintain `nameNormalized`, which the
14
+ * unique-among-active index depends on. They run on create AND update because a rename that
15
+ * skipped the update hook would leave the index entry pointing at the old name, and the
16
+ * duplicate it then permits surfaces months later as two live suppliers with one name.
17
+ */
18
+ function normalizeName(name) {
19
+ return name == null ? null : String(name).trim().toLowerCase();
20
+ }
21
+ var SupplierRepository = class extends Repository {
22
+ constructor(model, deps = {}) {
23
+ super(model, [validationChainPlugin([requireField("name", ["create"]), uniqueField("code", "Supplier code already exists")]), softDeletePlugin()], {
24
+ defaultLimit: deps.defaultLimit ?? 20,
25
+ maxLimit: deps.maxLimit ?? 100
26
+ });
27
+ this.on("before:create", async (context) => {
28
+ if (!context?.data) return;
29
+ if (!context.data.code && deps.codeGenerator) context.data.code = await deps.codeGenerator.nextCode();
30
+ if (context.data.name) {
31
+ context.data.name = String(context.data.name).trim();
32
+ context.data.nameNormalized = normalizeName(context.data.name);
33
+ }
34
+ });
35
+ this.on("before:update", async (context) => {
36
+ if (!context?.data) return;
37
+ if (context.data.name) {
38
+ context.data.name = String(context.data.name).trim();
39
+ context.data.nameNormalized = normalizeName(context.data.name);
40
+ }
41
+ });
42
+ }
43
+ /**
44
+ * One-time migration for rows written before `nameNormalized` existed.
45
+ *
46
+ * A pipeline update, so the value is derived from each document's own `name` in the database
47
+ * rather than read-modify-written per row. Rows that already carry a value are left alone: the
48
+ * filter is the guard, so a re-run is a no-op rather than a rewrite.
49
+ */
50
+ async backfillNameNormalized() {
51
+ return { modifiedCount: (await this.Model.updateMany({ $or: [
52
+ { nameNormalized: { $exists: false } },
53
+ { nameNormalized: null },
54
+ { nameNormalized: "" }
55
+ ] }, [{ $set: { nameNormalized: { $toLower: { $trim: { input: "$name" } } } } }])).modifiedCount || 0 };
56
+ }
57
+ };
58
+ function createSupplierRepository(model, deps = {}) {
59
+ return new SupplierRepository(model, deps);
60
+ }
61
+ //#endregion
62
+ export { SupplierRepository, createSupplierRepository };
@@ -0,0 +1,34 @@
1
+ import { a as SupplierFacet, s as SupplierPermissions } from "../../supplier.model-BcAfgyHu.mjs";
2
+ import { SupplierRepository } from "./supplier.repository.mjs";
3
+ import { AnyRecord, defineResource } from "@classytic/arc";
4
+ import { Model } from "mongoose";
5
+ //#region src/resources/supplier/supplier.resource.d.ts
6
+ interface SupplierResourceDeps<TSupplier extends SupplierFacet = SupplierFacet> {
7
+ model: Model<TSupplier>;
8
+ repository: SupplierRepository<TSupplier>;
9
+ permissions: SupplierPermissions;
10
+ /** Mount point. Default `/inventory/suppliers`. */
11
+ prefix?: string;
12
+ /** Extra filterable fields a country pack contributes (e.g. a tax-registration flag). */
13
+ extraFilterableFields?: Record<string, 'string' | 'boolean' | 'number'>;
14
+ /**
15
+ * The parser that ENFORCES which URL filter keys are accepted.
16
+ *
17
+ * `schemaOptions.query.filterableFields` below documents the filterable
18
+ * surface (OpenAPI, MCP tool schemas) — it does NOT reject anything. Measured
19
+ * on a live deployment: `GET /inventory/suppliers?nonsenseField=1` returned
20
+ * the full unfiltered page, HTTP 200, exactly like the request without it.
21
+ *
22
+ * That is the widening shape: an unrecognised filter key is dropped, so the
23
+ * caller asks for a narrower set and is answered with a broader one, with
24
+ * nothing to distinguish "nothing matched" from "your filter was discarded".
25
+ *
26
+ * Supply a parser with `allowedFilterFields` (mongokit's `invalidInput`
27
+ * defaults to `'throw'`) to turn an unlisted key into a 400 naming the field.
28
+ * Absent, behaviour is unchanged — this is additive.
29
+ */
30
+ queryParser?: Parameters<typeof defineResource>[0]['queryParser'];
31
+ }
32
+ declare function createSupplierResource<TSupplier extends SupplierFacet = SupplierFacet>(deps: SupplierResourceDeps<TSupplier>): import("@classytic/arc").ResourceDefinition<AnyRecord>;
33
+ //#endregion
34
+ export { SupplierResourceDeps, createSupplierResource };
@@ -0,0 +1,129 @@
1
+ import { r as asRecordRepository } from "../../entity-model-D-03ovSg.mjs";
2
+ import { defineResource } from "@classytic/arc";
3
+ import { ValidationError } from "@classytic/arc/utils";
4
+ import { createMongooseAdapter } from "@classytic/mongokit/adapter";
5
+ import { buildCrudSchemasFromModel } from "@classytic/mongokit/utils";
6
+ //#region src/resources/supplier/supplier.resource.ts
7
+ /**
8
+ * Supplier resource — arc CRUD over the facet, plus the go-live bulk import.
9
+ *
10
+ * Lifted from be-prod. CRUD is entirely arc's: `create`/`update` stamp the actor and run the
11
+ * sanitizer and hooks, `delete` soft-deletes through the repository plugin, and a subsequent GET
12
+ * 404s on its own. There is no controller override — the previous host controller was a
13
+ * `BaseController` subclass with an empty body whose only purpose was to carry
14
+ * `schemaOptions` + `tenantField`, both of which belong on the resource.
15
+ *
16
+ * ## `tenantField: false` is load-bearing
17
+ *
18
+ * Suppliers are company-wide (the shared-partner rule), and the schema has no `organizationId`.
19
+ * Without this, arc injects one on CREATE, mongoose's `strict: true` drops it, and every
20
+ * subsequent read filters on the missing field — a 404 on every GET/PATCH/DELETE of a document
21
+ * that was written successfully.
22
+ */
23
+ /** Import batch ceiling. Row-by-row work is linear, so the cap bounds the request, not the loop. */
24
+ const IMPORT_MAX_ROWS = 500;
25
+ function createSupplierResource(deps) {
26
+ const { model, repository, permissions, prefix = "/inventory/suppliers", extraFilterableFields, queryParser } = deps;
27
+ /**
28
+ * Wire schemas derived FROM THE MODEL, so a country pack's `extraFields` are accepted without
29
+ * anyone remembering to widen a hand-written schema — and `strictAdditionalProperties` still
30
+ * refuses anything the schema does not declare.
31
+ */
32
+ const parts = buildCrudSchemasFromModel(model, {
33
+ strictAdditionalProperties: true,
34
+ fieldRules: {
35
+ createdBy: { systemManaged: true },
36
+ updatedBy: { systemManaged: true },
37
+ nameNormalized: { systemManaged: true }
38
+ }
39
+ });
40
+ const crudSchemas = parts.crudSchemas ?? {
41
+ create: { body: parts.createBody },
42
+ update: { body: parts.updateBody },
43
+ get: { params: parts.params },
44
+ list: { querystring: parts.listQuery },
45
+ delete: { params: parts.params }
46
+ };
47
+ /**
48
+ * Bulk onboarding — row by row, with a per-row error report (Odoo `base_import` semantics).
49
+ *
50
+ * NOT arc's bulk preset, and the reason is specific: `createMany` / `insertMany` skip mongoose
51
+ * middleware and the repository hooks, so `nameNormalized` would be unset for every row and the
52
+ * FIRST batch would trip the partial unique index. Row-by-row also keeps the `uniqueField`
53
+ * validation and code generation live per row, and lets one bad row fail alone instead of
54
+ * aborting an operator's whole spreadsheet.
55
+ */
56
+ const importHandler = async (req, reply) => {
57
+ const items = req.body?.items;
58
+ if (!Array.isArray(items) || items.length === 0) throw new ValidationError("Provide a non-empty items array");
59
+ if (items.length > IMPORT_MAX_ROWS) throw new ValidationError(`Maximum ${IMPORT_MAX_ROWS} items per import — split larger batches`);
60
+ const created = [];
61
+ const failed = [];
62
+ for (const [index, item] of items.entries()) try {
63
+ const doc = await repository.create(item);
64
+ created.push({
65
+ index,
66
+ _id: doc._id,
67
+ code: doc.code
68
+ });
69
+ } catch (error) {
70
+ failed.push({
71
+ index,
72
+ name: item?.name,
73
+ error: error.message
74
+ });
75
+ }
76
+ /**
77
+ * `400` only when EVERY row failed — that is a bad request. A partial success is a `201`
78
+ * carrying the failure list, because rows were in fact created and reporting the whole call
79
+ * as a failure would invite the operator to re-import the ones that already exist.
80
+ */
81
+ return reply.code(failed.length === items.length ? 400 : 201).send({
82
+ createdCount: created.length,
83
+ failedCount: failed.length,
84
+ created,
85
+ failed
86
+ });
87
+ };
88
+ return defineResource({
89
+ ...queryParser ? { queryParser } : {},
90
+ name: "supplier",
91
+ displayName: "Suppliers",
92
+ tag: "Inventory - Suppliers",
93
+ prefix,
94
+ audit: true,
95
+ tenantField: false,
96
+ adapter: createMongooseAdapter({
97
+ model,
98
+ repository: asRecordRepository(repository)
99
+ }),
100
+ customSchemas: {
101
+ ...crudSchemas,
102
+ entity: parts.entitySchema ?? { type: "object" }
103
+ },
104
+ schemaOptions: { query: { filterableFields: {
105
+ name: "string",
106
+ code: "string",
107
+ type: "string",
108
+ paymentTerms: "string",
109
+ isActive: "boolean",
110
+ ...extraFilterableFields ?? {}
111
+ } } },
112
+ permissions: {
113
+ list: permissions.view,
114
+ get: permissions.view,
115
+ create: permissions.manage,
116
+ update: permissions.manage,
117
+ delete: permissions.delete ?? permissions.manage
118
+ },
119
+ routes: [{
120
+ method: "POST",
121
+ path: "/import",
122
+ summary: `Bulk supplier import — per-row error report (max ${IMPORT_MAX_ROWS}/batch)`,
123
+ permissions: permissions.manage,
124
+ rawHandler: importHandler
125
+ }]
126
+ });
127
+ }
128
+ //#endregion
129
+ export { createSupplierResource };
@@ -0,0 +1,2 @@
1
+ import { a as SupplierFacet, c as SupplierRefs, i as SupplierCodeGenerator, l as SupplierRepositoryDeps, o as SupplierModelDeps, s as SupplierPermissions } from "../../supplier.model-BcAfgyHu.mjs";
2
+ export { SupplierCodeGenerator, SupplierFacet, SupplierModelDeps, SupplierPermissions, SupplierRefs, SupplierRepositoryDeps };
@@ -0,0 +1 @@
1
+ export {};