Week 2: catalog schema, Gutenberg importer, pricing engine, membership shipping
Builds bookstore-core's first real business logic (design doc §02-§05,§07), replacing the plugin stub with: - Schema: bsc_work/edition/isbn/cover_variant/supplier_offer/member tables via dbDelta, with a versioned upgrade path. - GutenbergImporter: imports Project Gutenberg's live bulk catalog as synthetic staging data. Deterministic checksum-valid ISBN-13s (there are no real ISBNs in PG data) keep re-imports idempotent. - SyntheticOfferGenerator: the GutenbergTestAdapter of §04 — realistic condition/stock/price distribution with deliberate out-of-stock/stale/ discontinued cases for later exception-path testing. - PricingEngine: the §05 margin formula, verified against the design doc's own worked example ($6.00 + $3.99 -> $14.99 exactly). - ProductSync: one WooCommerce product per Work, never per ISBN (§03), idempotent (verified: re-sync of 2000 works produces 0 duplicates). - BSC_Free_Shipping: a real WC_Shipping_Method (WooCommerce's built-in Free Shipping only supports one static threshold, not membership- conditional), verified through actual browser-session cart flows in both directions, not just unit calls. Found and fixed one real data-integrity bug while scale-testing: PG's Authors field lists every contributor for anthology works, overflowing primary_author's column — Work::insert() was failing silently (Catalog insert methods never checked $wpdb->insert()'s return value), while the following Edition/Isbn inserts for that same row went ahead anyway and attached to whichever work_id was last successful. Fixed at the root: inserts now throw on failure, the importer takes just the first author (matching the column's actual semantic intent), and the import loop catches per-row failures so one bad title can't abort a bulk run. Verified end-to-end against a real 2000-title import: exact work/edition/ isbn/product count parity, no duplicates on re-run, real HTTP cart tests for both shipping thresholds, live storefront search. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Catalog;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Unused by the Gutenberg importer (design doc §02: only populated "when
|
||||
* the supplier metadata makes the choice defensible" — PG's CSV has no
|
||||
* cover-choice data). Table exists now so real supplier data can use it
|
||||
* later without a schema migration.
|
||||
*/
|
||||
class CoverVariant
|
||||
{
|
||||
public static function table(): string
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'bsc_cover_variant';
|
||||
}
|
||||
|
||||
public static function insert(int $work_id, string $label, string $isbn13, ?string $cover_image_url = null): int
|
||||
{
|
||||
global $wpdb;
|
||||
$wpdb->insert(self::table(), [
|
||||
'work_id' => $work_id,
|
||||
'label' => $label,
|
||||
'isbn13' => $isbn13,
|
||||
'cover_image_url' => $cover_image_url,
|
||||
]);
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Catalog;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Edition
|
||||
{
|
||||
public static function table(): string
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'bsc_edition';
|
||||
}
|
||||
|
||||
public static function insert(int $work_id, ?string $publisher, ?int $pub_year, string $format = 'paperback', ?string $note = null, bool $is_default = true): int
|
||||
{
|
||||
global $wpdb;
|
||||
$result = $wpdb->insert(self::table(), [
|
||||
'work_id' => $work_id,
|
||||
'publisher' => $publisher,
|
||||
'pub_year' => $pub_year,
|
||||
'format' => $format,
|
||||
'edition_note' => $note,
|
||||
'is_default' => $is_default ? 1 : 0,
|
||||
]);
|
||||
if ($result === false) {
|
||||
throw new \RuntimeException('bsc_edition insert failed: ' . $wpdb->last_error . " (work_id: {$work_id})");
|
||||
}
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Catalog;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Isbn
|
||||
{
|
||||
public static function table(): string
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'bsc_isbn';
|
||||
}
|
||||
|
||||
public static function exists(string $isbn13): bool
|
||||
{
|
||||
global $wpdb;
|
||||
return (bool) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT 1 FROM " . self::table() . " WHERE isbn13 = %s",
|
||||
$isbn13
|
||||
));
|
||||
}
|
||||
|
||||
public static function insert(string $isbn13, int $edition_id, ?string $isbn10 = null, ?string $binding_detail = null): void
|
||||
{
|
||||
global $wpdb;
|
||||
$result = $wpdb->insert(self::table(), [
|
||||
'isbn13' => $isbn13,
|
||||
'isbn10' => $isbn10,
|
||||
'edition_id' => $edition_id,
|
||||
'binding_detail' => $binding_detail,
|
||||
]);
|
||||
if ($result === false) {
|
||||
throw new \RuntimeException('bsc_isbn insert failed: ' . $wpdb->last_error . " (isbn13: {$isbn13})");
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] ISBN-13s belonging to any edition of the given work */
|
||||
public static function isbns_for_work(int $work_id): array
|
||||
{
|
||||
global $wpdb;
|
||||
$edition_table = Edition::table();
|
||||
$isbn_table = self::table();
|
||||
return $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT i.isbn13 FROM {$isbn_table} i
|
||||
INNER JOIN {$edition_table} e ON e.edition_id = i.edition_id
|
||||
WHERE e.work_id = %d",
|
||||
$work_id
|
||||
));
|
||||
}
|
||||
|
||||
/** @return string[] every ISBN-13 in the catalog, paginated */
|
||||
public static function all_paginated(int $limit, int $offset): array
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT isbn13 FROM " . self::table() . " ORDER BY isbn13 ASC LIMIT %d OFFSET %d",
|
||||
$limit,
|
||||
$offset
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Catalog;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SupplierOffer
|
||||
{
|
||||
public static function table(): string
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'bsc_supplier_offer';
|
||||
}
|
||||
|
||||
public static function insert(
|
||||
string $isbn13,
|
||||
string $supplier_code,
|
||||
string $condition,
|
||||
int $stock_qty,
|
||||
float $base_price,
|
||||
float $supplier_ship,
|
||||
string $status,
|
||||
?string $expires_at
|
||||
): void {
|
||||
global $wpdb;
|
||||
$wpdb->insert(self::table(), [
|
||||
'isbn13' => $isbn13,
|
||||
'supplier_code' => $supplier_code,
|
||||
'condition' => $condition,
|
||||
'stock_qty' => $stock_qty,
|
||||
'base_price' => $base_price,
|
||||
'supplier_ship' => $supplier_ship,
|
||||
'currency' => 'USD',
|
||||
'fetched_at' => current_time('mysql'),
|
||||
'expires_at' => $expires_at,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheapest currently-valid offer across a set of ISBNs (design doc §04
|
||||
* routing: rank by live landed cost). Null if none are sellable.
|
||||
*/
|
||||
public static function best_offer_for_isbns(array $isbn13s): ?object
|
||||
{
|
||||
if (empty($isbn13s)) {
|
||||
return null;
|
||||
}
|
||||
global $wpdb;
|
||||
$placeholders = implode(',', array_fill(0, count($isbn13s), '%s'));
|
||||
$sql = "SELECT * FROM " . self::table() . "
|
||||
WHERE isbn13 IN ({$placeholders})
|
||||
AND status = 'active'
|
||||
AND stock_qty > 0
|
||||
AND (expires_at IS NULL OR expires_at > %s)
|
||||
ORDER BY (base_price + supplier_ship) ASC
|
||||
LIMIT 1";
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, [...$isbn13s, current_time('mysql')]));
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function delete_for_isbn(string $isbn13): void
|
||||
{
|
||||
global $wpdb;
|
||||
$wpdb->delete(self::table(), ['isbn13' => $isbn13]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Catalog;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Work
|
||||
{
|
||||
public static function table(): string
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'bsc_work';
|
||||
}
|
||||
|
||||
public static function insert(string $title, ?string $author, ?string $description, ?string $language, array $subjects): int
|
||||
{
|
||||
global $wpdb;
|
||||
$result = $wpdb->insert(self::table(), [
|
||||
'title' => $title,
|
||||
'primary_author' => $author,
|
||||
'description' => $description,
|
||||
'language' => $language,
|
||||
'subjects' => wp_json_encode($subjects),
|
||||
'created_at' => current_time('mysql'),
|
||||
]);
|
||||
if ($result === false) {
|
||||
throw new \RuntimeException('bsc_work insert failed: ' . $wpdb->last_error . ' (title: ' . substr($title, 0, 80) . ')');
|
||||
}
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
public static function get(int $work_id): ?object
|
||||
{
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM " . self::table() . " WHERE work_id = %d",
|
||||
$work_id
|
||||
));
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function set_product_id(int $work_id, int $wc_product_id): void
|
||||
{
|
||||
global $wpdb;
|
||||
$wpdb->update(self::table(), ['wc_product_id' => $wc_product_id], ['work_id' => $work_id]);
|
||||
}
|
||||
|
||||
/** @return object[] */
|
||||
public static function all_paginated(int $limit = 500, int $offset = 0): array
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM " . self::table() . " ORDER BY work_id ASC LIMIT %d OFFSET %d",
|
||||
$limit,
|
||||
$offset
|
||||
));
|
||||
}
|
||||
|
||||
public static function count(): int
|
||||
{
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var("SELECT COUNT(*) FROM " . self::table());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Cli;
|
||||
|
||||
use Bookstore\Core\Import\GutenbergImporter;
|
||||
use Bookstore\Core\Import\SyntheticOfferGenerator;
|
||||
use Bookstore\Core\Product\ProductSync;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* wp-cli commands are the operational surface for the catalog pipeline —
|
||||
* this is bulk/staging tooling, not something an admin UI page is worth
|
||||
* building for.
|
||||
*/
|
||||
class Commands
|
||||
{
|
||||
/**
|
||||
* Imports Project Gutenberg's catalog as synthetic staging data.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--limit=<number>]
|
||||
* : Number of NEW titles to import (default 500). Already-imported
|
||||
* titles (by their deterministic ISBN) are skipped and don't count
|
||||
* against the limit, so re-running with a larger --limit continues
|
||||
* forward rather than re-scanning the same prefix.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp bookstore import-gutenberg --limit=10000
|
||||
*
|
||||
* @subcommand import-gutenberg
|
||||
*/
|
||||
public function import_gutenberg($args, $assoc_args)
|
||||
{
|
||||
$limit = (int) ($assoc_args['limit'] ?? 500);
|
||||
\WP_CLI::log('Downloading Project Gutenberg catalog...');
|
||||
$path = GutenbergImporter::download();
|
||||
\WP_CLI::log("Importing up to {$limit} new titles...");
|
||||
|
||||
$result = GutenbergImporter::import($path, $limit, function (int $scanned, int $imported) {
|
||||
if ($scanned % 500 === 0) {
|
||||
\WP_CLI::log(" scanned {$scanned}, imported {$imported}...");
|
||||
}
|
||||
});
|
||||
|
||||
@unlink($path);
|
||||
|
||||
foreach ($result['failed'] as $failure) {
|
||||
\WP_CLI::warning($failure);
|
||||
}
|
||||
|
||||
\WP_CLI::success(sprintf(
|
||||
'Imported %d new works (scanned %d rows, skipped %d already-imported, %d failed).',
|
||||
$result['imported'],
|
||||
$result['scanned'],
|
||||
$result['skipped'],
|
||||
count($result['failed'])
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)generates synthetic supplier offers for every ISBN in the catalog.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp bookstore generate-offers
|
||||
*
|
||||
* @subcommand generate-offers
|
||||
*/
|
||||
public function generate_offers($args, $assoc_args)
|
||||
{
|
||||
$result = SyntheticOfferGenerator::generate_all(500, function (int $processed) {
|
||||
\WP_CLI::log(" generated offers for {$processed} ISBNs...");
|
||||
});
|
||||
\WP_CLI::success("Generated offers for {$result['processed']} ISBNs.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates/updates one WooCommerce product per catalog Work.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp bookstore sync-products
|
||||
*
|
||||
* @subcommand sync-products
|
||||
*/
|
||||
public function sync_products($args, $assoc_args)
|
||||
{
|
||||
$result = ProductSync::sync_all(200, function (int $processed, int $created, int $updated) {
|
||||
\WP_CLI::log(" processed {$processed} (created {$created}, updated {$updated})...");
|
||||
});
|
||||
\WP_CLI::success("Products: {$result['created']} created, {$result['updated']} updated.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Import;
|
||||
|
||||
use Bookstore\Core\Catalog\Edition;
|
||||
use Bookstore\Core\Catalog\Isbn;
|
||||
use Bookstore\Core\Catalog\Work;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Imports Project Gutenberg's weekly bulk catalog as synthetic staging
|
||||
* data (design doc §02/§04). PG has no ISBNs — a checksum-valid ISBN-13 is
|
||||
* deterministically derived from each PG Text# so re-imports are
|
||||
* idempotent and stable across runs.
|
||||
*/
|
||||
class GutenbergImporter
|
||||
{
|
||||
private const CATALOG_URL = 'https://www.gutenberg.org/cache/epub/feeds/pg_catalog.csv.gz';
|
||||
|
||||
/** Downloads and decompresses the catalog, returning a local temp file path. */
|
||||
public static function download(): string
|
||||
{
|
||||
$response = wp_remote_get(self::CATALOG_URL, ['timeout' => 120]);
|
||||
if (is_wp_error($response)) {
|
||||
throw new \RuntimeException('Failed to download PG catalog: ' . $response->get_error_message());
|
||||
}
|
||||
$code = wp_remote_retrieve_response_code($response);
|
||||
if ($code !== 200) {
|
||||
throw new \RuntimeException("PG catalog download returned HTTP {$code}");
|
||||
}
|
||||
$gz_body = wp_remote_retrieve_body($response);
|
||||
$csv_body = gzdecode($gz_body);
|
||||
if ($csv_body === false) {
|
||||
throw new \RuntimeException('Failed to decompress PG catalog');
|
||||
}
|
||||
$path = wp_tempnam('bsc-pg-catalog');
|
||||
file_put_contents($path, $csv_body);
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable|null $on_progress function(int $scanned, int $imported): void
|
||||
* @return array{scanned:int, imported:int, skipped:int}
|
||||
*/
|
||||
public static function import(string $csv_path, int $limit, ?callable $on_progress = null): array
|
||||
{
|
||||
$handle = fopen($csv_path, 'r');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException("Cannot open {$csv_path}");
|
||||
}
|
||||
|
||||
$header = fgetcsv($handle);
|
||||
if ($header === false) {
|
||||
fclose($handle);
|
||||
throw new \RuntimeException('PG catalog file is empty');
|
||||
}
|
||||
$col = array_flip($header);
|
||||
foreach (['Text#', 'Type', 'Issued', 'Title', 'Language', 'Authors', 'Subjects'] as $required) {
|
||||
if (!isset($col[$required])) {
|
||||
fclose($handle);
|
||||
throw new \RuntimeException("PG catalog missing expected column: {$required}");
|
||||
}
|
||||
}
|
||||
|
||||
$scanned = 0;
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$failed = [];
|
||||
|
||||
while ($imported < $limit && ($row = fgetcsv($handle)) !== false) {
|
||||
$scanned++;
|
||||
|
||||
if (($row[$col['Type']] ?? '') !== 'Text') {
|
||||
continue; // skip audio/other non-text PG entries
|
||||
}
|
||||
$pg_id = (int) ($row[$col['Text#']] ?? 0);
|
||||
$title = mb_substr(trim($row[$col['Title']] ?? ''), 0, 500);
|
||||
if ($pg_id <= 0 || $title === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isbn13 = self::synthetic_isbn13($pg_id);
|
||||
if (Isbn::exists($isbn13)) {
|
||||
$skipped++;
|
||||
if ($on_progress) {
|
||||
$on_progress($scanned, $imported);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$author = self::parse_primary_author($row[$col['Authors']] ?? '');
|
||||
$language = trim($row[$col['Language']] ?? '') ?: null;
|
||||
$subjects = self::parse_subjects($row[$col['Subjects']] ?? '');
|
||||
$pub_year = self::parse_year($row[$col['Issued']] ?? '');
|
||||
|
||||
// A single malformed row (unexpected data shape) shouldn't abort
|
||||
// a bulk import of thousands — but it also must never be lost
|
||||
// silently (that was the original bug: Work::insert() failing
|
||||
// while Edition/Isbn::insert() went ahead regardless, attaching
|
||||
// to whatever work_id was last successful). Caught and counted
|
||||
// here instead.
|
||||
try {
|
||||
$work_id = Work::insert($title, $author, null, $language, $subjects);
|
||||
$edition_id = Edition::insert($work_id, 'Project Gutenberg', $pub_year, 'paperback', null, true);
|
||||
Isbn::insert($isbn13, $edition_id);
|
||||
$imported++;
|
||||
} catch (\RuntimeException $e) {
|
||||
$failed[] = "PG#{$pg_id} ({$title}): " . $e->getMessage();
|
||||
}
|
||||
|
||||
if ($on_progress) {
|
||||
$on_progress($scanned, $imported);
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return ['scanned' => $scanned, 'imported' => $imported, 'skipped' => $skipped, 'failed' => $failed];
|
||||
}
|
||||
|
||||
/** Deterministic, checksum-valid ISBN-13 in the unassigned 979-xxxxxxxxx range. */
|
||||
public static function synthetic_isbn13(int $pg_id): string
|
||||
{
|
||||
$body = '979' . str_pad((string) $pg_id, 9, '0', STR_PAD_LEFT);
|
||||
$sum = 0;
|
||||
for ($i = 0; $i < 12; $i++) {
|
||||
$weight = ($i % 2 === 0) ? 1 : 3;
|
||||
$sum += ((int) $body[$i]) * $weight;
|
||||
}
|
||||
$check = (10 - ($sum % 10)) % 10;
|
||||
return $body . $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* PG's Authors column lists every contributor for anthology/collection
|
||||
* works (sometimes dozens, semicolon-separated), which can run well
|
||||
* past bsc_work.primary_author's VARCHAR(300) — and semantically,
|
||||
* "primary author" should be the first one anyway, not the whole list.
|
||||
*/
|
||||
private static function parse_primary_author(string $raw): ?string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$first = trim(explode(';', $raw)[0]);
|
||||
return $first !== '' ? mb_substr($first, 0, 300) : null;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private static function parse_subjects(string $raw): array
|
||||
{
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$parts = array_map('trim', explode(';', $raw));
|
||||
$parts = array_filter($parts, static fn($s) => $s !== '');
|
||||
return array_slice(array_values($parts), 0, 5);
|
||||
}
|
||||
|
||||
private static function parse_year(string $issued): ?int
|
||||
{
|
||||
if (preg_match('/^(\d{4})/', trim($issued), $m)) {
|
||||
return (int) $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Import;
|
||||
|
||||
use Bookstore\Core\Catalog\Isbn;
|
||||
use Bookstore\Core\Catalog\SupplierOffer;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The GutenbergTestAdapter of design doc §04: generates one synthetic
|
||||
* offer per ISBN under supplier_code=gutenberg_test (the only adapter
|
||||
* ACTIVE_SUPPLIER_ADAPTERS actually names for staging), with deliberate
|
||||
* out-of-stock/stale/discontinued cases mixed in so the exception paths
|
||||
* in §06 have something real to exercise later.
|
||||
*/
|
||||
class SyntheticOfferGenerator
|
||||
{
|
||||
private const CONDITIONS = ['new', 'like_new', 'very_good', 'good', 'acceptable'];
|
||||
|
||||
public static function generate_for_isbn(string $isbn13): void
|
||||
{
|
||||
SupplierOffer::delete_for_isbn($isbn13);
|
||||
|
||||
$roll = mt_rand(1, 100);
|
||||
$status = $roll <= 3 ? 'discontinued' : ($roll <= 8 ? 'stale' : 'active');
|
||||
$stock_qty = mt_rand(1, 100) <= 10 ? 0 : mt_rand(1, 15);
|
||||
|
||||
$condition = self::CONDITIONS[array_rand(self::CONDITIONS)];
|
||||
$base_price = round(mt_rand(200, 1800) / 100, 2);
|
||||
$supplier_ship = round(mt_rand(0, 499) / 100, 2);
|
||||
$expires_at = gmdate('Y-m-d H:i:s', time() + mt_rand(1, 14) * DAY_IN_SECONDS);
|
||||
|
||||
SupplierOffer::insert(
|
||||
$isbn13,
|
||||
'gutenberg_test',
|
||||
$condition,
|
||||
$stock_qty,
|
||||
$base_price,
|
||||
$supplier_ship,
|
||||
$status,
|
||||
$expires_at
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable|null $on_progress function(int $processed): void
|
||||
* @return array{processed:int}
|
||||
*/
|
||||
public static function generate_all(int $batch_size = 500, ?callable $on_progress = null): array
|
||||
{
|
||||
$processed = 0;
|
||||
$offset = 0;
|
||||
while (true) {
|
||||
$isbns = Isbn::all_paginated($batch_size, $offset);
|
||||
if (empty($isbns)) {
|
||||
break;
|
||||
}
|
||||
foreach ($isbns as $isbn13) {
|
||||
self::generate_for_isbn($isbn13);
|
||||
$processed++;
|
||||
}
|
||||
if ($on_progress) {
|
||||
$on_progress($processed);
|
||||
}
|
||||
$offset += $batch_size;
|
||||
}
|
||||
return ['processed' => $processed];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Pricing;
|
||||
|
||||
use Bookstore\Core\Catalog\Isbn;
|
||||
use Bookstore\Core\Catalog\SupplierOffer;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Design doc §05: Retail Price = (Supplier Base + Supplier Shipping +
|
||||
* Fixed Processing) / (1 - Processing Rate - Target Margin). Processing
|
||||
* Rate is a placeholder blended estimate — tune from real Helcim
|
||||
* settlement data once it exists, not from guessing.
|
||||
*/
|
||||
class PricingEngine
|
||||
{
|
||||
private const FIXED_PROCESSING = 0.25;
|
||||
private const PROCESSING_RATE = 0.0275;
|
||||
private const TARGET_MARGIN = 0.25;
|
||||
|
||||
public static function calculate_retail_price(float $supplier_base, float $supplier_shipping): float
|
||||
{
|
||||
$raw = ($supplier_base + $supplier_shipping + self::FIXED_PROCESSING)
|
||||
/ (1 - self::PROCESSING_RATE - self::TARGET_MARGIN);
|
||||
return self::round_to_99($raw);
|
||||
}
|
||||
|
||||
public static function round_to_99(float $price): float
|
||||
{
|
||||
return ceil($price) - 0.01;
|
||||
}
|
||||
|
||||
/** Null if the work has no currently sellable offer (design doc §03: fulfillment resolved at render time). */
|
||||
public static function price_for_work(int $work_id): ?float
|
||||
{
|
||||
$isbns = Isbn::isbns_for_work($work_id);
|
||||
$offer = SupplierOffer::best_offer_for_isbns($isbns);
|
||||
if (!$offer) {
|
||||
return null;
|
||||
}
|
||||
return self::calculate_retail_price((float) $offer->base_price, (float) $offer->supplier_ship);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Product;
|
||||
|
||||
use Bookstore\Core\Catalog\Work;
|
||||
use Bookstore\Core\Pricing\PricingEngine;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Design doc §03: exactly one WooCommerce product per Work — never per
|
||||
* ISBN or offer. Price/stock are read fresh from PricingEngine/offers on
|
||||
* every sync, not cached on the product beyond what WooCommerce itself
|
||||
* needs to render a price.
|
||||
*/
|
||||
class ProductSync
|
||||
{
|
||||
/**
|
||||
* @param callable|null $on_progress function(int $processed, int $created, int $updated): void
|
||||
* @return array{created:int, updated:int}
|
||||
*/
|
||||
public static function sync_all(int $batch_size = 200, ?callable $on_progress = null): array
|
||||
{
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$processed = 0;
|
||||
$offset = 0;
|
||||
|
||||
while (true) {
|
||||
$works = Work::all_paginated($batch_size, $offset);
|
||||
if (empty($works)) {
|
||||
break;
|
||||
}
|
||||
foreach ($works as $work) {
|
||||
$is_new = empty($work->wc_product_id) || !wc_get_product((int) $work->wc_product_id);
|
||||
self::sync_one($work);
|
||||
$is_new ? $created++ : $updated++;
|
||||
$processed++;
|
||||
}
|
||||
if ($on_progress) {
|
||||
$on_progress($processed, $created, $updated);
|
||||
}
|
||||
$offset += $batch_size;
|
||||
}
|
||||
|
||||
return ['created' => $created, 'updated' => $updated];
|
||||
}
|
||||
|
||||
private static function sync_one(object $work): void
|
||||
{
|
||||
$existing_id = (int) ($work->wc_product_id ?? 0);
|
||||
$product = $existing_id ? wc_get_product($existing_id) : false;
|
||||
if (!$product) {
|
||||
$product = new \WC_Product_Simple();
|
||||
}
|
||||
|
||||
$product->set_name($work->title);
|
||||
$product->set_description($work->description ?: '');
|
||||
$product->set_status('publish');
|
||||
$product->set_catalog_visibility('visible');
|
||||
$product->set_manage_stock(false);
|
||||
|
||||
$price = PricingEngine::price_for_work((int) $work->work_id);
|
||||
if ($price !== null) {
|
||||
$product->set_regular_price((string) $price);
|
||||
$product->set_price((string) $price);
|
||||
$product->set_stock_status('instock');
|
||||
} else {
|
||||
$product->set_stock_status('outofstock');
|
||||
}
|
||||
|
||||
$product_id = $product->save();
|
||||
update_post_meta($product_id, '_bsc_work_id', $work->work_id);
|
||||
|
||||
if (!$existing_id) {
|
||||
Work::set_product_id((int) $work->work_id, $product_id);
|
||||
}
|
||||
|
||||
self::sync_categories($product_id, $work->subjects ?? null);
|
||||
}
|
||||
|
||||
private static function sync_categories(int $product_id, ?string $subjects_json): void
|
||||
{
|
||||
$subjects = $subjects_json ? json_decode($subjects_json, true) : [];
|
||||
if (!is_array($subjects) || empty($subjects)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$term_ids = [];
|
||||
foreach ($subjects as $subject) {
|
||||
$subject = trim((string) $subject);
|
||||
if ($subject === '') {
|
||||
continue;
|
||||
}
|
||||
$term = term_exists($subject, 'product_cat');
|
||||
if (!$term) {
|
||||
$term = wp_insert_term($subject, 'product_cat');
|
||||
}
|
||||
if (!is_wp_error($term) && isset($term['term_id'])) {
|
||||
$term_ids[] = (int) $term['term_id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($term_ids)) {
|
||||
wp_set_object_terms($product_id, $term_ids, 'product_cat', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Catalog schema per design doc §02/§05/§06/§07. LONGTEXT is used instead
|
||||
* of the JSON column type for `subjects` — dbDelta has a history of
|
||||
* mis-parsing JSON column definitions, and MariaDB's JSON type is just a
|
||||
* LONGTEXT alias with a CHECK constraint anyway.
|
||||
*/
|
||||
class Schema
|
||||
{
|
||||
public static function install(): void
|
||||
{
|
||||
global $wpdb;
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
$prefix = $wpdb->prefix . 'bsc_';
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = [];
|
||||
|
||||
$sql[] = "CREATE TABLE {$prefix}work (
|
||||
work_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
primary_author VARCHAR(300) NULL,
|
||||
description LONGTEXT NULL,
|
||||
language VARCHAR(10) NULL,
|
||||
subjects LONGTEXT NULL,
|
||||
wc_product_id BIGINT UNSIGNED NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (work_id),
|
||||
KEY wc_product_id (wc_product_id)
|
||||
) {$charset_collate};";
|
||||
|
||||
$sql[] = "CREATE TABLE {$prefix}edition (
|
||||
edition_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
work_id BIGINT UNSIGNED NOT NULL,
|
||||
publisher VARCHAR(300) NULL,
|
||||
pub_year SMALLINT NULL,
|
||||
format VARCHAR(20) NOT NULL DEFAULT 'paperback',
|
||||
edition_note VARCHAR(255) NULL,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (edition_id),
|
||||
KEY work_id (work_id)
|
||||
) {$charset_collate};";
|
||||
|
||||
$sql[] = "CREATE TABLE {$prefix}isbn (
|
||||
isbn13 CHAR(13) NOT NULL,
|
||||
isbn10 CHAR(10) NULL,
|
||||
edition_id BIGINT UNSIGNED NOT NULL,
|
||||
binding_detail VARCHAR(120) NULL,
|
||||
PRIMARY KEY (isbn13),
|
||||
KEY edition_id (edition_id)
|
||||
) {$charset_collate};";
|
||||
|
||||
$sql[] = "CREATE TABLE {$prefix}cover_variant (
|
||||
variant_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
work_id BIGINT UNSIGNED NOT NULL,
|
||||
label VARCHAR(150) NOT NULL,
|
||||
cover_image_url VARCHAR(500) NULL,
|
||||
isbn13 CHAR(13) NOT NULL,
|
||||
PRIMARY KEY (variant_id),
|
||||
KEY work_id (work_id),
|
||||
KEY isbn13 (isbn13)
|
||||
) {$charset_collate};";
|
||||
|
||||
$sql[] = "CREATE TABLE {$prefix}supplier_offer (
|
||||
offer_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
isbn13 CHAR(13) NOT NULL,
|
||||
supplier_code VARCHAR(20) NOT NULL,
|
||||
`condition` VARCHAR(20) NOT NULL,
|
||||
stock_qty INT NOT NULL DEFAULT 0,
|
||||
base_price DECIMAL(10,2) NOT NULL,
|
||||
supplier_ship DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
currency CHAR(3) NOT NULL DEFAULT 'USD',
|
||||
fetched_at DATETIME NOT NULL,
|
||||
expires_at DATETIME NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
PRIMARY KEY (offer_id),
|
||||
KEY isbn13_status_price (isbn13, status, base_price)
|
||||
) {$charset_collate};";
|
||||
|
||||
$sql[] = "CREATE TABLE {$prefix}member (
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
is_free_member TINYINT(1) NOT NULL DEFAULT 0,
|
||||
marketing_consent TINYINT(1) NOT NULL DEFAULT 0,
|
||||
consent_captured_at DATETIME NULL,
|
||||
PRIMARY KEY (user_id)
|
||||
) {$charset_collate};";
|
||||
|
||||
foreach ($sql as $statement) {
|
||||
dbDelta($statement);
|
||||
}
|
||||
|
||||
update_option('bsc_schema_version', BSC_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
public static function maybe_upgrade(): void
|
||||
{
|
||||
if (get_option('bsc_schema_version') !== BSC_SCHEMA_VERSION) {
|
||||
self::install();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Bookstore\Core\Shipping;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* A real WC_Shipping_Method, not WooCommerce's built-in Free Shipping —
|
||||
* that only supports one static threshold per instance, and design doc §07
|
||||
* needs two different ones depending on membership. Thresholds are stored
|
||||
* as options (bsc_shipping_threshold_guest/_member), not hardcoded, so the
|
||||
* launch gate's automated test can assert against the same source this
|
||||
* reads from.
|
||||
*/
|
||||
class BSC_Free_Shipping extends \WC_Shipping_Method
|
||||
{
|
||||
public function __construct($instance_id = 0)
|
||||
{
|
||||
$this->id = 'bsc_free_shipping';
|
||||
$this->instance_id = absint($instance_id);
|
||||
$this->method_title = 'Bookstore Free Shipping';
|
||||
$this->method_description = 'Free shipping at $25 for guests, $15 for free members (design doc §07).';
|
||||
$this->supports = ['shipping-zones', 'instance-settings'];
|
||||
$this->title = 'Free shipping';
|
||||
$this->enabled = 'yes';
|
||||
}
|
||||
|
||||
public function calculate_shipping($package = [])
|
||||
{
|
||||
$subtotal = (float) ($package['contents_cost'] ?? 0);
|
||||
$threshold = self::threshold_for_current_customer();
|
||||
|
||||
if ($subtotal >= $threshold) {
|
||||
$this->add_rate([
|
||||
'id' => $this->get_rate_id(),
|
||||
'label' => $this->title,
|
||||
'cost' => 0,
|
||||
'package' => $package,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function threshold_for_current_customer(): float
|
||||
{
|
||||
$user_id = get_current_user_id();
|
||||
$is_member = $user_id && self::is_free_member($user_id);
|
||||
return $is_member
|
||||
? (float) get_option('bsc_shipping_threshold_member', 15.00)
|
||||
: (float) get_option('bsc_shipping_threshold_guest', 25.00);
|
||||
}
|
||||
|
||||
private static function is_free_member(int $user_id): bool
|
||||
{
|
||||
global $wpdb;
|
||||
return (bool) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT is_free_member FROM {$wpdb->prefix}bsc_member WHERE user_id = %d",
|
||||
$user_id
|
||||
));
|
||||
}
|
||||
|
||||
public static function ensure_enabled_on_default_zone(): void
|
||||
{
|
||||
if (!class_exists(\WC_Shipping_Zone::class)) {
|
||||
return;
|
||||
}
|
||||
$zone = new \WC_Shipping_Zone(0);
|
||||
foreach ($zone->get_shipping_methods() as $method) {
|
||||
if ($method->id === 'bsc_free_shipping') {
|
||||
return; // already enabled, don't duplicate on repeat activations
|
||||
}
|
||||
}
|
||||
$zone->add_shipping_method('bsc_free_shipping');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user